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:
types.ts—<Type>FieldInput extends BaseFieldInput(everything optional, what a project author writes indefineCollection) and<Type>Field extends BaseField(resolved — every default applied,required,label,admin, etc. always present). Both carryreadonly type.config.ts—function <type>(options?): <Type>Field. Applies defaults via nested spread merge; seedate/config.tsfor the canonical deep-merge shape when a field has nested options (time: { ...defaults, ...options?.time }).validator.ts—<type>FieldToValidator({ field }): string, wrapped withapplyBaseValidators()(addsv.optional()when the field isn’t required). Returns a Convex validator source string, e.g."v.string()".inputSchema.ts—<type>FieldToInputSchema({ field }): ZodType, wrapped withapplyBaseInputSchemaMeta(). Returns the Zod schema the admin form and the server mutation both validate against.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 anADMIN_FIELDSentry:{ type, interfaceType, validator, defaultValue, kind: "data" }(kind: "ui"for a non-data layout field liketabs/row).fields/types.ts— add<Type>Fieldto theAdminFieldunion.fields/validators/index.tsandfields/inputSchemas/index.ts— add acase ADMIN_FIELDS.<type>.type:arm to each exhaustive switch, calling the field’s own<type>FieldToValidator/<type>FieldToInputSchema. TypeScript’s exhaustiveness check on theAdminFieldunion 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):
Field.tsx— exports<Type>FieldAdmin(props: FieldComponentProps<...>). Builds the control(s) for the field, resolves theadmin.components.Inputoverride chain viauseFieldControlOverride(), then renders the matchingforms/leaf component (TextField, or a new one underforms/— see below) wrapped in<AdminFieldBinding>, passingrenderControlthrough.Cell.tsx— exports<Type>FieldCell(props: CellComponentProps<...>)for the collection list-view table.columnDef.tsx— exports<type>FieldToColumnDef(...), the TanStack Table column factory that wires<Type>FieldCellinto aColumnDef.testFixture.ts— exports aFieldFixture(fieldType, a configuredfieldDef, andvalid/invalid/emptyvalues) used by the shared@vexcms/react/testingcontract suites.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/groupregister none) —fieldToFilterInputComponentreturnsundefinedand 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— norow/fieldKey/isTitleField, since a display has no column and never links anywhere on its own. Skip it for a type whosecellis already a reasonable read-only presentation (most scalar types) —fieldToDisplayComponentfalls back to the registeredcell, adapted, when nodisplayis registered, so every data field type still resolves to SOMETHING. Register a dedicated<Type>FieldDisplaywhen the type’s value needs more than its (often truncated, column-width-constrained) cell shows —json’s ownDisplay.tsxreuses the sameJsonEditoritsField.tsxdoes, justreadOnly;group/array/blocks’s own recurse throughfieldToDisplayComponentagain for each sub-field, via the sharedfields/DisplayFieldList.tsxhelper.
The two override slots
Section titled “The two override slots”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 adminField.tsxresolves this chain viauseFieldControlOverride()and passes the result asrenderControlinto theforms/leaf; each stage receivesFieldControlProps(id,value,onChange,onBlur,readOnly,invalid,describedBy) and the previous stage’srenderDefault.admin.components.Field— whole-field override, resolved one level up (beforeField.tsxitself renders), receivingFieldComponentProps(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.
Worked example
Section titled “Worked example”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.
Tests + verification
Section titled “Tests + verification”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.
pnpm --filter @vexcms/core typecheckpnpm --filter @vexcms/react typecheckthen add the field to a collection in apps/test and render it in the
create modal + list view.