Show navigationHide navigation

Content model

Entries are JSON files#

Each content record is a JSON file under cms/content/<collection>/. The shape is:

json
{
  "sys": { "id": "stable-or-uuid", "type": "collectionName", "status": "draft" },
  "fields": { "fieldName": "value" }
}
  • sys.id identifies the entry. Singleton-style collections often use a fixed file such as homePage-0000.json. Collections with many entries typically use UUID-based filenames.
  • sys.type matches the collection key in cms/schema.json (for example post, author).
  • sys.status controls the entry lifecycle. Values: merged (on main, baseline), draft (new, hidden from public), changed (first save promotes from draft; also used after edits on a workspace branch), published (optional; set via publishEntry() / tooling, not the entry editor), archived (soft-deleted, hidden). New entries default to draft. The query() API automatically excludes draft and archived entries from public page results. Legacy entries without a status field are treated as merged. Which Git branch the public site reads is controlled only by Publish in the header branch menu (publishBranch()), not by entry status alone.

Companion markdown files#

Fields with format: 'markdown' are not stored inside the JSON. Instead, each markdown field is stored in a separate companion .md file alongside the entry JSON:

cms/content/homePage/
  homePage-0000.json           structured fields (title, etc.)
  homePage-0000.body.md        markdown body
  homePage-0000.footer.md      markdown footer

The naming convention is {collection}-{id}.{fieldName}.md. The system determines which fields are markdown from the collection config — no sentinel values or special prefixes are used. On read, query() and getFile() automatically merge companion file content into the entry's fields object. If a companion file is missing, the field value defaults to "".

The exact folders and filenames depend on your schema and how entries were created. Check cms/schema.json to see which collections use hasMany and which are singletons.

Schema: cms/schema.json#

The data model lives in cms/schema.json at the repo root — plain JSON, hand-editable, the single source of truth. It declares:

  • Top-level project settingsprojectName, contentFolder, mediaFolder, mediaAllowedFormats, git.baseBranch / git.publishedPointerBranch.
  • Collections — name, human label, optional hasMany, and fields (label, format, optional required / hint / entryTitle / searchable, optional list: true for repeatable strings, boolean defaults/labels, select options and defaults, reference options, conditional branches, rich-text configuration, etc.).
  • Search — optional search.publicCollections map for collections that map to public URLs.

cms/octocms.config.ts is a thin TypeScript binding that re-exports the auto-generated cms/__generated__/schema.ts (a literal-typed mirror of cms/schema.json) so query() keeps narrow types end-to-end. Do not edit cms/octocms.config.ts or anything in cms/__generated__/ by hand — those files are regenerated from the JSON.

Three ways to change the schema#

PathBest for
Visual editor at /cms/modelMost schema work — adding/renaming/deleting collections and fields, reordering, type changes. Validates, predicts content impact, runs entry migrations, and commits everything (schema + regenerated artefacts + migrated entries) in one atomic Git commit. See the Content Model editor guide.
Hand-edit cms/schema.jsonBulk changes, code-review-friendly diffs, or programmatic generation. Run npm run types:gen (validates + regenerates types), then npm run docs:gen and npm run agent-docs:gen, then commit everything together. CI's *:check scripts will reject a commit where regenerated files have drifted from the JSON.
Schema editing APIBuilding custom tooling on top of the same engine the visual editor uses. See Schema editing API — exposes diffSchema, migrateEntry, previewSchemaChange, and saveSchema.

Both the visual editor and npm run types:gen go through the same validateConfig — invalid schemas are rejected with the same error messages either way.

Singletons vs many entries#

  • If a collection has hasMany: true, the UI expects multiple files (for example many posts).
  • If hasMany is omitted, the collection behaves as a single logical entry (or a small fixed set of files), such as one home page document.

Field options (required, hint)#

On each field object in cms/schema.json you can set:

  • required?: true — The admin editor blocks Save until the value is valid (non-empty string/text/markdown/image UUID; at least one list item when string + list: true is required; valid url / color when required; valid number when required; valid json syntax when required; select must choose allowed option(s); references respect required plus reference.min / reference.max). Validation runs in the browser and again in the saveFile server action (validateEntryFields).
  • hint?: string — Optional short helper copy shown in muted text under the control (plain text only; not markdown).
  • list?: true — Only valid with format: 'string'. Stores a JSON array of strings in the entry and shows the tag-style editor. Other formats ignore this flag; use reference to link to other entries.

Creating a new entry (Add) runs newFile, which fills fields with schema defaults where defined: boolean.defaultBoolean, select.defaultOption, select.defaultOptions (see Field types).

These options apply across supported formats where relevant (string, text, markdown, boolean, select, image, number, datetime, json, slug, url, color, reference). See Field types for format-specific storage.

Media as entries#

Images are not only files on disk: each upload also creates a media entry JSON under cms/media/ (configurable via the top-level mediaContentFolder setting in cms/schema.json). Every upload requires a Title on the asset (stored in the JSON); that title is used as the default alt when query() resolves image fields. Metadata such as width, height, and a small blur data URL may be stored when the file is uploaded. Image fields store the media entry UUID on disk; at read time they become a structured object with src, alt, and optional blur/dimensions (see Media).