Schema Metadata Reference
The zod .meta() keys that shape the generated form - labels, grouping, control hints, references and canFilter.
The file to edit is your backend schema. Everything on this page is zod .meta() on a createCRUD schema - see Controlling the UI From the Backend for how those schemas reach the UI.
A Worked Schema
An excerpt from a real employee schema, showing every technique in one place:
export const employeeInputSchema = z.object({
// pick a specific control
avatar: z.url().meta({ fieldHint: "avatar" }).optional(),
// three fields on one row: the GROUP's classes go on its FIRST field
fname: z.string().max(50).meta({
group: "fullName",
groupClassName: "grid grid-cols-2 gap-4",
label: "First Name",
}),
mname: z.string().max(50).optional().meta({
group: "fullName",
label: "Middle Name",
}),
lname: z.string().max(50).optional().meta({
group: "fullName",
className: "col-span-2", // this one field spans the grid
label: "Last Name",
}),
// enum + filterable
gender: z.enum(Gender).optional().meta({ group: "bio", canFilter: true }),
// a reference, rendered as a type-ahead over the users module
user: $objectId.meta({
ref: "users",
refLabel: ["name", "email"],
fieldHint: "autocomplete",
}).optional(),
// long text becomes a textarea automatically (maxLength > 100)
address_1: z.string().max(300).optional().meta({
group: "address",
groupClassName: "grid grid-cols-2 gap-4",
className: "col-span-2",
}),
// an array of objects → a repeatable field group with add/remove
identity: z.array(
z.object({
kind: z.enum(IdentityKind),
description: z.optional(z.string().max(150)),
documents: z.array(
z.url().meta({ label: "Identity documents", fieldHint: "upload" }),
).optional(),
}),
).meta({
label: "Identify your employee",
description: "A proof of identity is a must in order to proceed",
}),
// a nested object → its own fieldset, with its own internal grouping
bankDetails: z.object({
bankName: z.string().max(100).meta({
group: "bankName",
groupClassName: "grid grid-cols-2 gap-4",
}),
accountName: z.string().max(50).meta({ group: "bankName" }),
accountNumber: z.string().max(120).optional(),
}).meta({
label: "Bank Details",
description: "How is this employee going to be paid?",
}).optional(),
// `.describe()` is the same as `.meta({ description })`
contract: z.object({
kind: z.enum(ContractKind).meta({ label: "Contract Kind" }),
joinedAt: z.coerce.date().describe("Start the payroll period").meta({
group: "dates",
groupClassName: "grid grid-cols-2 gap-4",
canFilter: true,
}),
department: $objectId.meta({ ref: "departments" }).meta({
group: "work",
canFilter: true,
}),
}).optional(),
}).meta() can be chained - z.coerce.date().meta({ label }).optional().meta({ group }) merges both objects. That is why you often see one .meta() on the inner type (for ref) and another after .optional() (for layout).
The Metadata Keys
| Key | Put it on | Effect in Thunder UI |
|---|---|---|
label | any field, object, array | The visible label. On an object or array it becomes the fieldset legend. Passed through t(). |
description (or .describe()) | any field, object, array | Help text under the field, or under a fieldset's legend. Passed through t(). |
group | a field | Renders this field in the same row/grid as its neighbours carrying the same value. See below. |
groupClassName | the first field of a group | Tailwind classes for the group container, e.g. "grid grid-cols-2 gap-4". |
groupStyle | the first field of a group | Inline styles for the group container. |
className | a field | Tailwind classes for that one field, e.g. "col-span-2" to span a grid. |
style | a field | Inline styles for that one field. |
fieldHint | a field | Picks a specific control. See the fieldHint table. |
canFilter | a field | Required for the field to appear in the list page's filter menu. |
ref | an ObjectId field | Module this field references; renders a searchable picker. |
refLabel | a ref field | Property or properties to display, e.g. ["fname", "lname"]. |
refValue | a ref field | Property to submit. Defaults to _id. |
refFilters | a ref field | Filters applied when querying the referenced module. |
example | a field | Placeholder text. |
requirementKey | a field | Render only when the watched field's value equals this field's name. |
fileType, fileSize | an upload field | Accepted types and max size. |
filterSchema | an object field | Schema name for fieldHint: "filters". |
queryValue, ignoreQueryValue | a field | Prefill from a URL query parameter, or opt out of it. |
Any other key on a schema property is carried through to the field object untouched, so a custom override component can read whatever it likes.
Grouping: Two Rules That Surprise People
1. Grouping is by consecutive fields
Thunder UI walks the properties in order and starts a new group each time the group value changes. Two blocks with the same group name separated by a differently-grouped field become two separate groups - the name is not an identifier. Declare grouped fields next to each other.
// ✅ one row of three
fname: …meta({ group: "fullName", groupClassName: "grid grid-cols-2 gap-4" }),
mname: …meta({ group: "fullName" }),
lname: …meta({ group: "fullName" }),
// ❌ two separate "fullName" groups, because `email` interrupts
fname: …meta({ group: "fullName" }),
email: …,
lname: …meta({ group: "fullName" }),2. The group's classes come from its first field
groupClassName and groupStyle are read off fields[0] of each group, so set them on the first member only. Setting them on a later member has no effect.
Fields with no group are rendered one per row.
canFilter Is Opt-In
A field does not appear in the list page's filter menu unless it carries canFilter: true. The menu additionally excludes url, hidden, object and array fields, so canFilter on those does nothing.
This is the single most commonly missed key. The field shows up as a table column and a form input regardless, but stays unfilterable until the backend says otherwise.
What Plain Zod Already Implies
Most of the UI follows from the type alone - reach for .meta() only when you need to override one of these:
| On the backend | In Thunder UI |
|---|---|
.optional() | field is not required; label gets an "(optional)" suffix |
required (no .optional()) | field is required, with a generated message |
z.string().max(n) where n <= 100 | single-line input, with maxLength validation |
z.string().max(n) where n > 100, or no max | textarea |
z.string().min(n) | minLength validation |
z.number().min()/.max() | number input with range validation |
z.boolean() | switch |
z.enum(X) | dropdown (multiselect when inside an array) |
z.url() | url input - or an uploader with fieldHint |
z.email() | email input |
z.e164() | international phone input |
z.coerce.date() | date input (add fieldHint: "datetime-local" for a time too) |
z.array(z.string()) | tag input |
z.array(z.object({…})) | repeatable field group with add/remove |
z.object({…}) | nested fieldset |
z.record(…) | JSON textarea |
.default(v) | pre-filled value |
$objectId.meta({ ref }) | searchable reference picker |
z.literal(v) / a const | hidden, submitted as that value |
.regex(...) | pattern validation |
A field's required state comes from the parent object's required list, which zod derives from .optional(). Nothing in the UI needs to be told about it.
After Editing a Schema
None of this takes effect until the SDK is rebuilt:
deno task generate:sdk -v <latest-version> --overwriteSee Two Kinds of Update.