Content files
Where content lives, the shape of site.json and page files, and how schema changes are migrated. Read this to edit JSON by hand or to understand what the editor writes.
Content directory
content/ site.json pages/ index.json about.jsonloadContent(contentDir, model) reads that directory with Node fs, validates it, and returns { site, pages }. Schema failures throw ContentValidationError with the file path and a list of path + message issues. Missing files and broken JSON throw ContentReadError.
The home page uses slug index. Other slugs are lowercase kebab-case (about). Filenames are not required to match the slug, but the starter does.
site.json
name— site namenameMarks— optional ranges insidename(start,end, and any ofweight,italic,font).fontissans,serif, ormono.weightisregular,medium,semibold, orbold. A legacynameEmsubstring is accepted on input and stored as one serif italic mark.url— canonical origin, used for canonical URLs, the sitemap, and absolute Open Graph imageslang— optional BCP 47 language tag (en,pl,pt-BR) for<html lang>; the template defaults toenseo— site defaults: optionaltitleTemplate(%sis replaced with the page SEO title),description, andogImagefavicon— optional{ src, alt }, the browser tab icon. A legacylogokey is read asfaviconand stored under the new name.brandLogo— optional{ src, alt, height? }. Shown in the header and footer instead of the name;heightis 16–64 px (28 when omitted). The icon box beside the name is gone: the brand is either the name or this image.theme.modeislightordark. A legacysystemreads aslight.theme—mode(light,dark, orsystem, defaultlight) andmotion(defaulttrue)header—variant(bar,centered,floating),tone,sticky,links, and andactions, up to two header buttons shown left to right (the last is the primary button; an empty list hides them). OlderctaandsignInfields are still read, assignInthencta, and are rewritten asactionson the next savefooter—variant(simple,columns,wordmark),tone, optionaltext,legal, andbadge(the columns pill), and up to threecolumnsof{ title, links }
Pages
Each content/pages/*.json file:
slug,titleseo—title,description, optionalogImage,noindex(defaultfalse),nofollow(defaultfalse), optionalcanonical(absolute URL or site-relative path)sections— ordered{ id, type, variant, tone, motion?, props }
The 404 page is a system page: the editor lists it apart, edits its content, and refuses to create, delete, rename or promote it, and the starter serves it as /404.html. Section ids are unique within a page. props are checked against the Zod schema registered for type. variant and tone default per type (tone defaults to base). Headings stay plain strings. marks styles ranges inside heading the same way nameMarks styles the wordmark. A legacy em substring is accepted on input and stored as one serif italic mark. Links are { label, href, newTab?, id?, trackingId?, style? }. style is primary, secondary or tertiary and applies to section buttons; the editor’s link popover sets it, and leaving it unset keeps the look each section was designed with. newTab forces target="_blank" and rel="noopener noreferrer". id is a lowercase slug, unique on the page, and it cannot match a section anchor. trackingId is still accepted and rendered as data-analytics when present in the JSON, but the editor no longer shows a field for it.
Content versions
Content files carry a schemaVersion (default 1; a file without one is version 1). When a schema change would reject files written before it, raise the version in the content model and add the step that upgrades the old shape:
export const contentModel = { sections, site, schemaVersion: 2, migrations: { // upgrades version 1 to 2; `kind` is 'page' or 'site' 1: (doc, kind) => (kind === 'page' && 'name' in doc ? { ...omit(doc, 'name'), title: doc.name } : doc), },}- Every step below
schemaVersionmust exist, orbearminion()throws at startup. loadContent(), the editor API andcontentCollections()run the steps in memory before validating, so schemas only describe the current shape and never seeschemaVersion.- Files are rewritten, stamped with the current version, when the editor saves them. Nothing is migrated on disk until then.
- A file newer than the site (
schemaVersionabove the model’s) is refused with aschemaVersionerror rather than guessed at.
Sections the site no longer defines
A page can hold a section whose type the site stopped defining (a section directory was removed or renamed). The site cannot draw it, so the build fails on it, by design. The editor still has to open the page and let you fix it:
- The editor API validates a page with the tolerant schema: a section with an unregistered
typeand a valididis kept as it is, with anypropsand extra keys. Registered sections are validated in full. PUT /pages/:slugkeeps an orphan the stored page already has, and refuses a new or retyped one (400, pathsections.N.type).- The preview leaves orphans out (
withoutOrphanSections), and the section panel shows the section as “Unknown (type)” with a Delete button. loadContent(dir, model, { tolerateOrphans: true })andcontentCollections(model, { tolerateOrphans: true })accept them for config and dev. Without this,astro devexits at startup and the editor never opens. The starter turns it on inastro.config.ts, and incontent.config.tsfor dev only (import.meta.env.DEV), soastro buildstays strict.- With the dev option on, collection entries can hold orphans, so render pages through
withoutOrphanSections(page, registry). The starter’slib/content.tsdoes.