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.ididentifies the entry. Singleton-style collections often use a fixed file such ashomePage-0000.json. Collections with many entries typically use UUID-based filenames.sys.typematches the collection key incms/schema.json(for examplepost,author).sys.statuscontrols the entry lifecycle. Values:merged(on main, baseline),draft(new, hidden from public),changed(first save promotes fromdraft; also used after edits on a workspace branch),published(optional; set viapublishEntry()/ tooling, not the entry editor),archived(soft-deleted, hidden). New entries default todraft. Thequery()API automatically excludesdraftandarchivedentries from public page results. Legacy entries without astatusfield are treated asmerged. 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 settings —
projectName,contentFolder,mediaFolder,mediaAllowedFormats,git.baseBranch/git.publishedPointerBranch. - Collections — name, human label, optional
hasMany, and fields (label, format, optionalrequired/hint/entryTitle/searchable, optionallist: truefor repeatable strings,booleandefaults/labels,selectoptions and defaults, reference options, conditional branches, rich-text configuration, etc.). - Search — optional
search.publicCollectionsmap 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#
| Path | Best for |
|---|---|
Visual editor at /cms/model | Most 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.json | Bulk 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 API | Building 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
hasManyis 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 whenstring+list: trueis required; valid url / color when required; valid number when required; valid json syntax when required; select must choose allowed option(s); references respectrequiredplusreference.min/reference.max). Validation runs in the browser and again in thesaveFileserver action (validateEntryFields).hint?: string— Optional short helper copy shown in muted text under the control (plain text only; not markdown).list?: true— Only valid withformat: 'string'. Stores a JSON array of strings in the entry and shows the tag-style editor. Other formats ignore this flag; usereferenceto 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).