Skip to content

Adding a Field Type

A field type is defined in two halves that never import each other directly — core defines its shape/validation/schema, react defines its admin UI — and are wired together only through two registries: core’s ADMIN_FIELDS / AdminField union, and react’s reactAdapter.fields map.

The worked example below copies fields/text/ — the simplest data field.

The core half — packages/core/src/fields/<type>/

Section titled “The core half — packages/core/src/fields/<type>/”

Copy an existing field folder and edit its five files:

  1. types.ts — <Type>FieldInput extends BaseFieldInput (everything optional, what a project author writes in defineCollection) and <Type>Field extends BaseField (resolved — every default applied, required, label, admin, etc. always present). Both carry readonly type.
  2. config.ts — function <type>(options?): <Type>Field. Applies defaults via nested spread merge; see date/config.ts for the canonical deep-merge shape when a field has nested options (time: { ...defaults, ...options?.time }).
  3. validator.ts — <type>FieldToValidator({ field }): string, wrapped with applyBaseValidators() (adds v.optional() when the field isn’t required). Returns a Convex validator source string, e.g. "v.string()".
  4. inputSchema.ts — <type>FieldToInputSchema({ field }): ZodType, wrapped with applyBaseInputSchemaMeta(). Returns the Zod schema the admin form and the server mutation both validate against.
  5. index.ts — barrel: export * from "./config" / "./types" / "./validator" / "./inputSchema".

Then three shared registrations, all required for the new type to compile and be reachable:

  • fields/constants.ts — add an ADMIN_FIELDS entry: { type, interfaceType, validator, defaultValue, kind: "data" } (kind: "ui" for a non-data layout field like tabs/row).
  • fields/types.ts — add <Type>Field to the AdminField union.
  • fields/validators/index.ts and fields/inputSchemas/index.ts — add a case ADMIN_FIELDS.<type>.type: arm to each exhaustive switch, calling the field’s own <type>FieldToValidator/<type>FieldToInputSchema. TypeScript’s exhaustiveness check on the AdminField union is what catches a forgotten arm.

Finally, fields/index.ts gets export * from "./<type>".

The react half — packages/react/src/fields/<type>/

Section titled “The react half — packages/react/src/fields/<type>/”

Copy an existing react field folder (Field.tsx/Cell.tsx/ columnDef.tsx/testFixture.ts/index.ts):

  1. Field.tsx — exports <Type>FieldAdmin(props: FieldComponentProps<...>). Builds the control(s) for the field, resolves the admin.components.Input override chain via useFieldControlOverride(), then renders the matching forms/ leaf component (TextField, or a new one under forms/ — see below) wrapped in <AdminFieldBinding>, passing renderControl through.
  2. Cell.tsx — exports <Type>FieldCell(props: CellComponentProps<...>) for the collection list-view table.
  3. columnDef.tsx — exports <type>FieldToColumnDef(...), the TanStack Table column factory that wires <Type>FieldCell into a ColumnDef.
  4. testFixture.ts — exports a FieldFixture (fieldType, a configured fieldDef, and valid/invalid/empty values) used by the shared @vexcms/react/testing contract suites.
  5. index.ts — barrel re-exporting the three components.

If the field needs new list/layout mechanics (not just a different control), add the app form component first under forms/ — see the react-app-form rule. Otherwise map the new fieldDef directly onto an existing forms/ leaf’s props (most data fields with a single scalar control do this — see forms/TextField.tsx for the pattern: it reads useFieldContext() + useFormContext() only, resolves isFieldShowingError/fieldDescribedBy, and renders through FormFieldLayout).

Then register in packages/react/src/adapter.ts: import the new <Type>FieldAdmin/<Type>FieldCell pair and add [ADMIN_FIELDS.<type>.type]: { field: <Type>FieldAdmin, cell: <Type>FieldCell } to buildReactAdapter()’s fields map — reactAdapter.fields (consumed by fields/registry.ts’s fieldToFieldComponent/fieldToCellComponent) is the one source of truth both the collection edit view and the list view read from.

Two more slots on that same map entry are OPTIONAL, unlike field/cell:

  • filterInput — the list view’s filter-row value control (FilterInputProps<TField>: field, value, op, onChange, disabled?). Leave it off for a type with no meaningful single-value comparison (json/richtext/array/blocks/group register none) — fieldToFilterInputComponent returns undefined and the filter builder renders a stable placeholder instead of a control.
  • display — a read-only presentation of the field’s value, used anywhere a value needs to be shown without a form around it (e.g. the collection list’s row-inspect peek). DisplayComponentProps<TField>: field, value, document, collection — no row/fieldKey/ isTitleField, since a display has no column and never links anywhere on its own. Skip it for a type whose cell is already a reasonable read-only presentation (most scalar types) — fieldToDisplayComponent falls back to the registered cell, adapted, when no display is registered, so every data field type still resolves to SOMETHING. Register a dedicated <Type>FieldDisplay when the type’s value needs more than its (often truncated, column-width-constrained) cell shows — json’s own Display.tsx reuses the same JsonEditor its Field.tsx does, just readOnly; group/array/blocks’s own recurse through fieldToDisplayComponent again for each sub-field, via the shared fields/DisplayFieldList.tsx helper.

Both halves exist because of the two independent extension points a project can hook into without forking a component (see Admin Customization):

  • admin.components.Input — control-only override. The admin Field.tsx resolves this chain via useFieldControlOverride() and passes the result as renderControl into the forms/ leaf; each stage receives FieldControlProps (id, value, onChange, onBlur, readOnly, invalid, describedBy) and the previous stage’s renderDefault.
  • admin.components.Field — whole-field override, resolved one level up (before Field.tsx itself renders), receiving FieldComponentProps (name, fieldDef, readOnly, collection, renderDefault).

A new field type gets both for free by following the pattern above — neither slot needs type-specific wiring beyond registering in reactAdapter.fields.

fields/text/ is the template every data field with a single scalar input was copied from (email, url, number, textarea all follow it directly); fields/upload/ is the template for a field with an async side-effect and its own helper widgets (EmptyInput/FilledInput, utils.ts). Copy whichever is closer to the new field’s shape.

Colocate validator.test.ts + inputSchema.test.ts next to the core files, and Field.test.tsx/Cell.test.tsx/columnDef.test.ts next to the react files. Most field types need no server-side test of their own — the shared convex-test setup used where one is needed is documented in .agent/docs/standards/testing/convex-integration-testing.md.

Terminal window
pnpm --filter @vexcms/core typecheck
pnpm --filter @vexcms/react typecheck

then add the field to a collection in apps/test and render it in the create modal + list view.