The content model

Every content file is validated against a JSON Schema with additionalProperties: false. This page is the reference for what each content type requires.

Folder structure

Text
content/
  pages/          *.json, nested folders allowed - one file per page
  menus/          *.json, flat only - one file per menu
  redirects.json  a single file, not a folder
  settings.json   the site settings editors have saved - see Site settings
  drafts/         unpublished edits mirroring pages/ - written by the admin only
media/            uploaded images and video, beside content/ - never git-tracked

Pages

content/pages/*.json. Required fields:

FieldTypeNotes
schemaVersionintegerAlways 7 for newly authored content.
namestring, non-emptyLabel shown in the admin’s page tree — can differ from title.
titlestring, non-emptyRendered <title> / page.title in layouts.
typestring, non-emptyDeveloper-chosen — what kind of page this is ("page", "article", "product"), used by listings to filter. No fixed values.
layoutstring, non-emptyNames a file in theme/layouts/ (no extension).
publishedbooleanSee Core concepts.
sectionsarraySection instances — can be empty.

Three further fields are optional on every page, not tied to any particular type value:

FieldTypeNotes
authorstring, non-emptyAvailable as page.author.
publishDatestring, non-emptyNo enforced format — be consistent so theme sorting logic (and sort=-publishDate in search queries) behaves sensibly.
tagsarray of non-empty stringsCan be an empty array.

Whenever a page carries any of these, they’re automatically indexed by search with no "api": true flag needed — the basis for a blog-style listing (?pageType=article&sort=-publishDate), built entirely out of ordinary pages, not a distinct content type.

Nested pages

A URL maps directly to a file path, with the root URL (/) mapping to index.json. A page with children is a .json file sitting beside a same-named folder, not inside it:

Text
content/pages/
  index.json              ->  /
  about.json              ->  /about          (has children below)
  about/
    team.json              ->  /about/team
    careers.json           ->  /about/careers
  blog/
    hello-world.json       ->  /blog/hello-world
  404.json                 ->  see below

about.json and about/ are siblings, both directly under pages/ — never about/about.json. /blog is not a reserved namespace — it’s just an ordinary folder of pages, and a theme can call it anything.

Links to the site’s own pages are always written as paths — /about, /about#team — in link fields, menu items, site settings and rich text alike, never https://my-site.example/about. That’s what keeps them working when pages change:

  • Moving or renaming a page rewrites every link to it (and to pages under it) to the new address, in live pages, drafts, menus and site settings, in the same commit as the move. A redirect from the old address is added too, for links from elsewhere.
  • Deleting a page shows the editor what links to it first, with the option to redirect its address to another page.

A full address to the site’s own domain is left alone, and would go through a redirect. npm run check reports any internal link that doesn’t point at a published page.

content/menus/*.json, flat only. The filename (without .json) is the menu’s handle, referenced in layouts as menus.<handle>.items. Required: schemaVersion and items (array); each item requires label and url, both non-empty strings — there is no nested-submenu field. An optional name is the menu’s display name in the admin, available as menus.<handle>.name.

JSON
{
  "schemaVersion": 7,
  "name": "Main navigation",
  "items": [
    { "label": "Home", "url": "/" },
    { "label": "About", "url": "/about" },
    { "label": "Blog", "url": "/blog" }
  ]
}

Renaming the file changes the handle and empties every layout still using the old one, so change name to rename a menu.

Redirects

A single file, content/redirects.json, not a folder:

JSON
{
  "schemaVersion": 1,
  "entries": [
    { "from": "/old-path", "to": "/new-path" },
    {
      "from": "/legacy",
      "to": "/about",
      "note": "merged into About during the 2026 redesign"
    }
  ]
}

from and to are both required, non-empty strings. to must be a single internal path starting with exactly one / — never a protocol-relative // and never a full URL with a scheme. note is optional, free text, max 500 characters. Redirects only apply where no live page already exists at that URL — a real page always wins.

Site settings

content/settings.json holds the values of the settings the theme defines in theme/config/site_settings.json. It’s optional: without it, every setting has the theme’s default. See Site settings.

Media

media/ is a top-level folder, a sibling of content/ and theme/, deliberately not git-tracked. Files are uploaded through the admin’s media library or POST /v1/media — never copied in by hand — and named by the CMS from a hash of their contents. Accepted types: .jpg, .jpeg, .png, .gif, .webp, and .mp4/.webm video, up to the site’s upload limit (10MB by default). SVG is rejected outright.

An image setting ("format": "image") stores an object, not a bare URL:

JSON
{
  "id": "sec-hero",
  "type": "hero",
  "settings": {
    "heading": "Welcome",
    "image": {
      "url": "/media/barley-a1b2c3d4e5f6.jpg",
      "focalX": 0.5,
      "focalY": 0.3
    }
  }
}

A video setting stores { "url", "poster" }. Writing starter content before a server is running? The seed-media command adds images to media/ under exactly the name a real upload would get:

Terminal
cd vhost
npm run seed-media -- .. photo.jpg another.png
# photo.jpg -> /media/photo-3f9a2b7c1e04.jpg
# another.png -> /media/another-91cd4a08f2b1.png

Run it from vhost/, where the CMS is installed; the .. is the site folder.

The 404 page

content/pages/404.json is a convention, not a schema-required file. If it exists and is published, a genuinely missing URL renders it through the normal page pipeline but forces the HTTP status to 404. If it’s missing, unpublished, or fails to render, the CMS falls back to a plain JSON 404 body instead — always create one for a real site.

Hard constraints

  • schemaVersion is always 7 for pages, menus and site settings, 1 for redirects, on any freshly authored content.
  • Every section/block type referenced in content must exactly match a filename the theme defines.
  • Pages can nest arbitrarily; menus cannot.
  • additionalProperties: false everywhere — unknown fields fail validation, always.
  • Links to the site’s own pages are paths, never full addresses.