nextjs-cms

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.ts

See 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 → log

See 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