Architecture
How a section file becomes a form, a table, and an API, traced through the core modules.
Two lifecycles
nextjs-cms has two entry points that share the same section and field code.
1. Schema time (the CLI)
nextjs-cms update-sections
└─ SectionFactory.getSectionsSilently() load every *.section.ts with jiti
└─ section.build() → field.build() instantiate Section + Field classes
└─ generateFieldSQL(field) field class → column definition
└─ diff with MysqlTableChecker CREATE / ALTER / RENAME / DROP (with prompts)
└─ generateDrizzleSchema write dynamic-schemas/schema.tsSee Schema Sync.
2. Request time (the admin)
Browser ── GET /edit/posts/42 ──▶ page (RSC)
└─ tRPC: hasItemsSections.editItem → createEditPage()
└─ FieldFactory({ type: 'edit', sectionName, itemId, session })
├─ SectionFactory.getSectionForAdmin (privilege check)
├─ load row (+ locale row), destinationDb values
├─ field.build() (options from DB, thumbnails config)
└─ field.exportForClient() → JSON form payload
Browser ◀─ form rendered from payload (components/form/inputs/*)
Browser ── POST /api/submit/section/item/42 (FormData) ──▶ route handler
└─ new EditSubmit({ sectionName, itemId, user, postData })
├─ initialize() section + privilege + existing values
└─ submit() validate → unique → hooks → SQL → files → logSee Section Loading, Field System, and Submit Pipeline.
Key design decisions
Configs are plain objects with a build() method
textField({...}) returns a config object, not a class instance:
const config = textField({ name: 'title' })
// { name: 'title', type: 'text', build(): TextField }Configs are cheap, serializable (minus build), and safe to reference from other configs (headingField, for, conditionalRules, db.unique). Instances are created on demand with config.build() inside a request or CLI run, so per-request state (the submitted value, the current language) never leaks between requests.
One class per field, one method per lifecycle step
Every field extends Field (or FileField) and implements the same small lifecycle: setValue, checkRequired, prepareForSubmission, getSubmitValue, postSubmit, postSubmitRollback, exportForClient. The submit pipeline and the form builder only talk to that interface.
Runtime loading instead of bundling
Section files and the config are loaded at runtime with jiti, not bundled by Next.js. That's what lets the CLI and the admin share them, and what enables hot reload of sections without restarting next dev.
entityKind and is() instead of instanceof
Classes declare static [entityKind] = 'TextField', and the code checks types with is(field, TextField). This survives duplicated module instances (jiti vs. Next's bundle, dual ESM/CJS) where instanceof would fail.
The database is the contract
Content lives in plain MySQL tables with predictable names and columns. Websites read them directly (optionally via the generated Drizzle schema). There's no content API between the CMS and the website, and nothing in the table format is private to the CMS except the system tables.
Pages in this section
Discovery, validation, defaults, caching, and hot reload of cms.config.
Section LoadingSectionFactory, jiti, caching, validation, and the section classes.
Field SystemThe Field base class, the lifecycle, and the client payload.
Submit PipelineNewSubmit, EditSubmit, SimpleSectionSubmit, LocaleSubmit, hooks, rollback.
API LayertRPC routers, procedures, context, and route handlers.
AuthenticationJWT access and refresh tokens, CSRF, and the session hooks.
Localization InternalsDictionaries, getString, locale resolution, and _locales tables.
Schema Sync (CLI)How update-sections diffs sections against MySQL.
Plugin LoaderLoading plugin packages, router merging, and route rendering.