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:

schemas/employee.ts
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

KeyPut it onEffect in Thunder UI
labelany field, object, arrayThe visible label. On an object or array it becomes the fieldset legend. Passed through t().
description (or .describe())any field, object, arrayHelp text under the field, or under a fieldset's legend. Passed through t().
groupa fieldRenders this field in the same row/grid as its neighbours carrying the same value. See below.
groupClassNamethe first field of a groupTailwind classes for the group container, e.g. "grid grid-cols-2 gap-4".
groupStylethe first field of a groupInline styles for the group container.
classNamea fieldTailwind classes for that one field, e.g. "col-span-2" to span a grid.
stylea fieldInline styles for that one field.
fieldHinta fieldPicks a specific control. See the fieldHint table.
canFiltera fieldRequired for the field to appear in the list page's filter menu.
refan ObjectId fieldModule this field references; renders a searchable picker.
refLabela ref fieldProperty or properties to display, e.g. ["fname", "lname"].
refValuea ref fieldProperty to submit. Defaults to _id.
refFiltersa ref fieldFilters applied when querying the referenced module.
examplea fieldPlaceholder text.
requirementKeya fieldRender only when the watched field's value equals this field's name.
fileType, fileSizean upload fieldAccepted types and max size.
filterSchemaan object fieldSchema name for fieldHint: "filters".
queryValue, ignoreQueryValuea fieldPrefill 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 backendIn 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 <= 100single-line input, with maxLength validation
z.string().max(n) where n > 100, or no maxtextarea
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 consthidden, 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> --overwrite

See Two Kinds of Update.


On this page