nextjs-cms

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:

core/fields/text.ts (shape)
// 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

MethodCalled byResponsibility
build()FieldFactoryAsync setup before rendering: load select options from the DB, resolve thumbnail config...
exportForClient()FieldFactoryReturn a serializable payload for the form. Call setLanguage() first so label resolves.
setValue(value)SubmitReceive the raw submitted value (string from FormData, File...), parse it.
checkRequired()SubmitThrow a translated error if required and empty.
prepareForSubmission()SubmitValidate and transform: lengths, ranges, slug normalization, bcrypt, image decoding, sanitizing.
hasSqlNameAndValue()SubmitWhether this field writes a column (false for destinationDb fields, readonly fields, date ranges).
getSubmitValue()SubmitThe value for the SQL statement.
postSubmit(...)SubmitSide effects after the row is written: write files, rewrite join tables, save inline images.
postSubmitRollback()SubmitUndo postSubmit when a later step fails.
getConditionalGroup() / isConditional()Submit, schema syncNormalized 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 required as 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

FieldWhy it's special
DateRangeFieldTwo 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 / SelectMultipleFieldThree 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.
TagsFieldComma-joined column, or a join table; autoCompletion requires destinationDb (enforced by types and at runtime).
RichTextFieldSanitizes HTML (core/security/dom.ts), extracts base64 images, writes them, tracks them in editor_photos.
NumberFieldDiscriminated union on hasAutoIncrement; auto-increment forces adminGenerated: false.
PasswordFieldbcrypt in prepareForSubmission (once).

From field to form: FieldFactory

core/factories/FieldFactory.ts builds the payload for new/edit pages:

  1. Loads the section for the admin (privilege check).
  2. For edit: loads the row, the locale row, and destinationDb values; marks the identifier readonly.
  3. For category sections: hides parent_id and level.
  4. For new: emits only adminGenerated === true fields.
  5. Calls setLanguage, build(), and exportForClient() on each field.
  6. Groups and sorts fields (explicit order first, then array order; same for groups).
  7. 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.

Good to know:

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.