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
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:
| Field | Type | Notes |
|---|---|---|
schemaVersion | integer | Always 7 for newly authored content. |
name | string, non-empty | Label shown in the admin’s page tree — can differ from title. |
title | string, non-empty | Rendered <title> / page.title in layouts. |
type | string, non-empty | Developer-chosen — what kind of page this is ("page", "article", "product"), used by listings to filter. No fixed values. |
layout | string, non-empty | Names a file in theme/layouts/ (no extension). |
published | boolean | See Core concepts. |
sections | array | Section instances — can be empty. |
Three further fields are optional on every page, not tied to any particular type value:
| Field | Type | Notes |
|---|---|---|
author | string, non-empty | Available as page.author. |
publishDate | string, non-empty | No enforced format — be consistent so theme sorting logic (and sort=-publishDate in search queries) behaves sensibly. |
tags | array of non-empty strings | Can 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:
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 between pages
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.
Menus
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.
{
"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:
{
"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:
{
"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:
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
schemaVersionis always7for pages, menus and site settings,1for redirects, on any freshly authored content.- Every section/block
typereferenced in content must exactly match a filename the theme defines. - Pages can nest arbitrarily; menus cannot.
additionalProperties: falseeverywhere — unknown fields fail validation, always.- Links to the site’s own pages are paths, never full addresses.