Show navigationHide navigation
Content Model editor (visual schema editor)
OctoCMS ships a visual schema editor at /cms/model that lets
you see, create, rename, and delete content types without touching
cms/schema.json by hand.
The visual editor and the hand-edit flow are equivalent: both write the
same cms/schema.json and share the same validator, codegen, and content
migrations. Use whichever fits the moment.
Opening the editor#
The header has a Content Model link. It is always available — sign in
to /cms and click it, or go directly to /cms/model.
The list view shows every collection with its key, cardinality, field count, and entry count. Click a row to open its detail page.
Creating a content type#
Click Create content type in the top-right of the list view.
The dialog asks for:
| Field | Notes |
|---|---|
| Name | Human label shown in entry lists and the editor header. Required, max 50 chars. |
| API identifier | Collection key. Auto-derived from Name (camelCase) until you edit it manually. Must be a valid JavaScript identifier — letters, digits, $, _, no leading digit. Max 64 chars. Must be unique. |
| Cardinality | Many entries (a collection like blog posts) or Singleton (one entry, like a home page). Locked once entries exist. |
On submit, OctoCMS:
- Adds the new collection to
cms/schema.jsonwith a single defaulttitlefield (a requiredstringmarked as the entry title). - Validates the new schema (
validateConfig). - Regenerates every schema-driven file (
cms/__generated__/,docs/generated/schema.md,octocms/docs/). - Commits everything in a single commit on the active feature branch (or writes to disk in dev).
- Navigates to the new content type's detail page so you can review or extend it.
The default title field exists so the new collection passes
validateConfig (which requires at least one field) and so the generated
TypeScript types are immediately usable. You can rename or replace it
later — field-level editing is part of Phase 5.
Editing a content type#
On a content type's detail page (/cms/model/<key>), click
Edit to open the rename dialog. You can change:
- Name — always safe; just updates the human label.
- API identifier — renaming the key moves every entry file (and
companion
.md/.mdxfiles) in the same commit. The dialog runs an impact preview before showing the Save button so you can see exactly which entries will be moved. - Cardinality — editable only when the collection has zero entries. Once any entry exists, the toggle is locked with an explanatory tooltip. Delete every entry first if you really need to switch.
When you change only the Name or Cardinality, the save is one round-trip
(no preview needed). When you change the API identifier, the dialog runs
previewSchemaChange() first and asks you to confirm the move.
When you rename a content type, every reference field value in every
other collection that points at the old key is rewritten in place to
the new key in the same commit (e.g. author-a1.json →
writer-a1.json). The impact list shows you which entries get rewritten.
[!IMPORTANT] The
reference.collectionsallow-list on the field definition itself is not auto-updated when you rename a target collection — schema validation will reject the save if you forget to update it. Edit the reference field on the consuming content type to point at the new key in the same change.
Deleting a content type#
The More actions (⋯) menu on the detail page has a destructive
Delete content type action.
The delete dialog runs previewSchemaChange() and shows two impact lists:
- Entries that will be deleted — every entry inside this collection,
plus every companion
.md/.mdxfile. These are removed in the same commit that updatescms/schema.json. - Entries that reference this collection — entries in other
collections whose
referencefields point here. The orphaned reference keys are pruned automatically in the same commit (cardinality-onevalues becomenull; cardinality-manyvalues shrink). The list is informational so you know which entries got rewritten.
You must type the collection's name into the confirmation field to enable
the destructive button. There is no undo — use git revert on the schema
commit if you need to recover.
What happens on save#
Every save (create, edit, delete) is one atomic commit on the active feature branch:
cms/schema.json ← updated source of truth cms/__generated__/schema.ts ← regenerated literal-typed shim cms/__generated__/types.ts ← regenerated TypeScript types cms/__generated__/... ← every other generated file docs/generated/schema.md ← regenerated developer schema reference octocms/docs/{schema,overview,index}.md ← regenerated agent docs cms/content/<collection>/... ← migrated entries (renames or deletes)
In production, this is one GitHub commit via the multi-file commit
helper (commitMultipleFilesToGitHub). In dev, it's a sequence of local
filesystem writes.
After the commit lands, OctoCMS:
- Calls
buildJsons('')to bust public-page caches. - In production, the next Vercel build picks up the new
cms/__generated__/types.ts, soquery()resumes returning fully-typed results once the build completes.
Branch and publish flow#
Schema edits use the same branch workflow as content edits:
- The active branch is read from the
cms-active-branchcookie. - Production refuses schema writes when no feature branch is active
(
assertFeatureBranchForWritesIfRequired). Create a working branch from the header first. - "Publish" works the same as for content — switch the published-branch
pointer (
cms/pointers/<build-id>.json; see Multi-deploy). - Schema and content changes can ride the same PR — useful when you add a field and an entry that uses it together.
When to use the JSON file directly#
Editing cms/schema.json by hand is still fully supported and may be
better for:
- Bulk changes across many fields or collections.
- Diffing or reviewing schema in a code review.
- Programmatic generation (e.g. importing from an external schema).
After hand-editing, run npm run types:gen (or npm run checks to also
verify everything else). The visual editor will reflect your changes on
the next visit.
Editing fields#
The detail page lists every field on the content type. Each row exposes:
- A drag handle (
⋮⋮) — drag to reorder fields. The new order is saved on drop and is reflected in the generated TypeScript types and the editor UI. - A starred Title column — click any star to nominate that field as
the entry title. Only non-list
string,text, andslugfields qualify (matchingvalidateConfig's slug rules); other rows show a greyed-out star with a tooltip. - A row context menu (
⋯) — Edit field or Delete field.
Click the field label or the row context menu to open the Edit field dialog.
Adding a field#
Click Add field in the page header to open the two-step dialog:
- Pick a type. All 15 formats from
FIELD_FORMAT_METAappear as cards with the format key, the human label, and the storage description. - Configure the field. The form below the picker collects:
- Label — display name shown in the editor.
- Key — JS identifier auto-derived from the label (camelCase) until you edit it directly.
- Hint — optional helper text shown under the editor input.
- Required, Searchable, Entry title flags.
- Per-format options driven by
FIELD_FORMAT_META.optionFields(e.g.min/max/valueTypefor number,options+multiplefor select,cardinality+collectionsfor reference). - Conditional fields get a dedicated Branches sub-editor — add branches with either inline fields or a reuse-collection reference.
- Rich text fields get a Rich text configuration sub-editor — toggle toolbar buttons, allowed embeds (references, images, conditions), template variables, and custom embeddable components.
On save, the dialog calls saveSchema() which validates, regenerates
every artefact, and commits the change as a single commit.
Editing a field#
The Edit field dialog is the same form as Add but pre-filled. Two operations trigger an impact preview:
- Renaming the key — entries get the value moved from the old key to
the new key in the same commit. The dialog runs
previewSchemaChange()and lists every affected entry before save. - Changing the format — the in-place coercion table in
migrateContent.tshandles texty ↔ texty, boolean ↔ texty, and number ↔ texty. Anything riskier (markdown, richtext, reference, image, json, conditional, select) drops the value with a warning. The dialog surfaces those warnings so you can opt in or cancel.
Deleting a field#
Delete field runs previewSchemaChange() and shows the affected
entries. For markdown / richtext fields, the companion .md / .mdx
files are deleted alongside the JSON in the same commit.
You must type the field key into the confirmation field to enable the destructive button.
Reordering fields#
Grab the row by its drag handle and drop it onto another row. The save runs immediately; a toast confirms the new order. Reordering does not touch any entries — only the schema.
Impact preview#
Every destructive change runs previewSchemaChange() before save. The
preview lists each entry that will be rewritten and tags it with one of:
— the entry's data carries over (rename, format coercion succeeded, reference rewritten to a new key).
— content is dropped (deleted entry, removed field with a value, format change that couldn't be coerced, pruned orphaned reference).
Each row is a clickable link that opens the entry editor in a new tab — useful for spot-checking what's about to change without losing the dialog. Entries with no value at the affected field are skipped entirely (the impact list shows actual data motion, not theoretical reach).
Limitations#
- Reference allow-list updates are manual. When you rename a
collection, you must also update any
reference.collectionsarrays that named the old key (validation will reject the save otherwise). Reference values in entries get rewritten automatically. - No undo button. Schema commits are real Git commits. Revert with
git revertif needed.
Related#
- Schema editing API — the underlying server actions and pure-function library that the visual editor sits on top of.
- Content model — entry storage layout and the field-format catalogue.
- Generated types — what
npm run types:genproduces and how it integrates withquery().