Field System
The Field base class, the per-field lifecycle, config helpers, client export, and how fields reach the database and the admin UI.
Code: packages/nextjs-cms/src/core/fields/
Every field type is one file with three parts:
// 1. Options schema: the field-specific options, merged with baseFieldConfigSchema
const configSchema = z.strictObject({ placeholder: ..., minLength: ..., maxLength: ..., sanitize: ... })
// 2. The class: runtime behavior
export class TextField extends Field<'text', Config> { ... }
// 3. The helper: validates options, returns a config with build()
export function textField(field: z.infer<typeof optionsSchema>): TextFieldConfig {
const result = optionsSchema.safeParse(field)
if (!result.success) throw new Error(`[Field: ${field.name}]: ${z.prettifyError(result.error)}`)
return { ...field, type: 'text' as const, build: () => new TextField(field) }
}The Field base class
core/fields/field.ts. Constructor stores the common options: name, label (raw MultilingualString, generated from name when missing), required, defaultValue, order, conditionalRules, adminGenerated (default true), localized.
Lifecycle
| Method | Called by | Responsibility |
|---|---|---|
build() | FieldFactory | Async setup before rendering: load select options from the DB, resolve thumbnail config... |
exportForClient() | FieldFactory | Return a serializable payload for the form. Call setLanguage() first so label resolves. |
setValue(value) | Submit | Receive the raw submitted value (string from FormData, File...), parse it. |
checkRequired() | Submit | Throw a translated error if required and empty. |
prepareForSubmission() | Submit | Validate and transform: lengths, ranges, slug normalization, bcrypt, image decoding, sanitizing. |
hasSqlNameAndValue() | Submit | Whether this field writes a column (false for destinationDb fields, readonly fields, date ranges). |
getSubmitValue() | Submit | The value for the SQL statement. |
postSubmit(...) | Submit | Side effects after the row is written: write files, rewrite join tables, save inline images. |
postSubmitRollback() | Submit | Undo postSubmit when a later step fails. |
getConditionalGroup() / isConditional() | Submit, schema sync | Normalized conditional rules. |
Language
Error messages and labels are translated. Submit calls field.setLanguage(user.language, fallback); fields use this.getLocalizedLabel() and getString(key, this.language, params).
FileField
photo, video, and document extend the abstract FileField (core/fields/fileField.ts), which adds extensions, setFile(), setFileName(), writeToFile(), and cleanupReplacedFile(). The pipeline checks is(field, FileField) to:
- Treat
requiredas satisfied on edit when a file already exists. - Delete previous files when a new one is uploaded.
- Map to
VARCHAR(50)in schema sync.
Special cases
| Field | Why it's special |
|---|---|
DateRangeField | Two columns. name is "start|end"; hasSqlNameAndValue() is false, and submit uses getSqlNamesAndValues() via an is(field, DateRangeField) guard. Schema sync emits two column definitions. |
SelectField / SelectMultipleField | Three option sources (options, db, section); with destinationDb they write a join table in postSubmit and no column. Nested category selects receive one value per level. |
TagsField | Comma-joined column, or a join table; autoCompletion requires destinationDb (enforced by types and at runtime). |
RichTextField | Sanitizes HTML (core/security/dom.ts), extracts base64 images, writes them, tracks them in editor_photos. |
NumberField | Discriminated union on hasAutoIncrement; auto-increment forces adminGenerated: false. |
PasswordField | bcrypt in prepareForSubmission (once). |
From field to form: FieldFactory
core/factories/FieldFactory.ts builds the payload for new/edit pages:
- Loads the section for the admin (privilege check).
- For edit: loads the row, the locale row, and
destinationDbvalues; marks the identifierreadonly. - For category sections: hides
parent_idandlevel. - For new: emits only
adminGenerated === truefields. - Calls
setLanguage,build(), andexportForClient()on each field. - Groups and sorts fields (explicit
orderfirst, then array order; same for groups). - Warns about misconfigurations (localized identifier, localized fields without
localization).
From payload to input: the admin
apps/cms/components/form/form-inputs.tsx switches on input.type and renders one component from components/form/inputs/ (text-form-input.tsx, photo-form-input.tsx, ...). Client-side validation uses the Zod schemas from nextjs-cms/validators (src/validators/*.ts), which mirror the server rules so errors show before submitting.
Validators are environment-neutral: they run in the browser. They must not import anything that pulls in the database client, sharp, or fs. Import leaf modules directly (for example core/helpers/inline-image.ts) instead of barrels.
From field to column: schema sync
generateFieldSQL() in cli/lib/update-sections.ts maps each class to a MySQL column (is(input, TextField) → VARCHAR(maxLength ?? 255), ...), then appends NOT NULL or DEFAULT. cli/utils/schema-generator.ts does the same for the Drizzle schema. See Schema Sync.
Conditional rules
core/helpers/conditional-rules.ts holds the single evaluator used by both the admin UI (components/sections/conditional-field.tsx) and submit (Submit.checkConditionalField), so a field is never visible but dropped, or hidden but saved. Server-side, checkbox controllers are coerced with isCheckboxChecked() because FormData sends "on".
Adding a field type touches all of the above. Follow Adding a Field Type.