Chapter 07Build
The content model
Three field types, not one more. One JSON file per page and per language, validated by the same schema at build time and on write.
One file per page and per language
src/content/pages/
├── fr/
│ ├── home.json
│ ├── services.json
│ └── contact.json
└── en/
├── home.json
├── services.json
└── contact.json
The file name becomes the URL segment: services.json answers at
/en/services/. home.json is a special case: it answers at the root
of its language, /en/.
Lowercase letters, digits and hyphens only — that is the whitelist the write function
applies: src/content/pages/{language}/{page}.json. A
My_File.json would be built by Astro but refused when publishing.
The shape of a page
Three sections, always the same:
{
"meta": {
"title": "Martin Bakery — Home",
"description": "Sourdough bread baked every morning, in Nantes."
},
"blocks": {
"hero": {
"title": {
"type": "text",
"value": "Sourdough bread, every morning",
"style": { "size": "3xl", "weight": "bold", "align": "center", "color": "primary" }
},
"intro": {
"type": "richtext",
"value": "We <strong>knead</strong> every night. <a href=\"/contact\">Come and see us</a>."
},
"visual": {
"type": "media",
"kind": "image",
"src": "bakehouse-at-dawn.webp",
"alt": "The bakehouse at dawn",
"width": 1600,
"height": 900
}
}
},
"collections": {
"reviews": [
{
"id": "a-001",
"quote": { "type": "text", "value": "The best bread in town.", "style": { "size": "lg", "italic": true } },
"author": { "type": "text", "value": "Claire D.", "style": { "size": "sm", "color": "muted" } }
}
]
}
}| Section | Role | Required |
|---|---|---|
meta | Title, description, share image. Feeds <title>, the meta description, Open Graph. | yes |
blocks | Simple fields, grouped by block: blocks.hero.title. | yes |
collections | Lists: testimonials, services, case studies. | no |
blocks has exactly two levels: a block name, then a field name.
That is not arbitrary — it is what makes a path readable in the data-cms
attribute and checkable at build time.
The three field types
text — the common case
{
"type": "text",
"value": "Sourdough bread, every morning",
"style": {
"size": "3xl",
"weight": "bold",
"italic": false,
"align": "center",
"color": "primary"
}
}
Headings, labels, paragraphs without inline formatting. The style object is
optional in the file: every key has a default
(base, regular, false, left,
primary).
richtext — when emphasis or a link is needed
{
"type": "richtext",
"value": "A <strong>complete</strong> service. <a href=\"/contact\">Let's talk</a>."
}No style object: formatting lives in the markup. Allowed tags:
strong, em, a[href], br, ul,
ol, li. Seven tags, one attribute. Everything else
is stripped — in the browser on paste, and on the server before writing.
Every allowed tag is a layout decision handed to the client. Allow <h2> in
richtext and the heading hierarchy — therefore the SEO — becomes changeable by accident. The
short list is the boundary from chapter 1, applied to markup.
media — image
{
"type": "media",
"kind": "image",
"src": "bakehouse-at-dawn.webp",
"alt": "The bakehouse at dawn",
"width": 1600,
"height": 900
}src— a file name fromsrc/media/, not a path. Lowercase, digits, hyphens, extensionjpg,pngorwebp.alt— required, non-empty. Labelled “Image description” on the client side.width/height— positive integers, computed server-side on upload.
media — video
{
"type": "media",
"kind": "video",
"provider": "youtube",
"videoId": "aqz-KE-bpKQ",
"title": "A night in the bakehouse"
}
No video file is ever uploaded. A heavy file in Git breaks the repository and
the builds. The client pastes a link, the tool extracts the provider and the id.
provider is youtube or vimeo; poster is
optional.
Style tokens
Five axes, closed lists. These values come from a single file from which the Zod schema draws its enums and from which the overlay toolbar builds its buttons: a button offering a value the build would reject is impossible by construction.
| Key | Values | Default |
|---|---|---|
size | xs · sm · base · lg · xl · 2xl · 3xl | base |
weight | thin · light · regular · medium · semibold · bold | regular |
italic | true · false | false |
align | left · center · right | left |
color | primary · secondary · muted · accent · inverse | primary |
Each value becomes a class (cms-size-3xl, cms-color-accent…) pointing
at a CSS variable of the site theme. No hard-coded value anywhere — see
Theme and styles.
Lists
A list is an array of items under collections. Each item carries an
id and fields like anywhere else.
"collections": {
"reviews": [
{ "id": "a-001", "quote": { … }, "author": { … } },
{ "id": "a-002", "quote": { … }, "author": { … } }
]
}
Enforced format: one lowercase letter, a hyphen, at least three digits —
a-001, t-014. An id is stable and never
reassigned, even after deletion: it is the key linking the DOM to the JSON. Reusing
it would attach the edits of a deleted item to a brand new one. The write function rejects
any duplicate within a list.
An empty array is valid: a page from which the client removed every testimonial is still a page. Items are reconciled by id, not by position — so a translation can order its testimonials differently without texts sliding from one item to another.
Site configuration
src/content/site.json holds what is common to every page. It is validated at
build time by the same schema as the rest, but it is outside the write
whitelist: the client cannot change it from the overlay.
{
"name": "Martin Bakery",
"locale": "en",
"navigation": [
{ "label": "Home", "href": "/" },
{ "label": "Contact us", "href": "mailto:contact@martin-bakery.com" }
],
"contact": {
"email": "contact@martin-bakery.com",
"phone": "+33 2 40 00 00 00",
"address": "3 rue du Four, 44000 Nantes"
},
"footer": { "legal": "© Martin Bakery — All rights reserved" }
}This is structure — navigation, contact details, legal line — so it belongs to the developer. The boundary is set on delivery day, and it is enforced by the code, not merely announced.
The constraints, in one table
| Field | Constraint | Otherwise |
|---|---|---|
meta.title | 60 characters max | Build fails, publish refused (422) |
meta.description | 160 characters max | same |
meta.ogImage | optional | — |
text.value | string, may be empty | — |
style.* | a value from the enum | Build fails — no silent fallback |
image.alt | non-empty | Build fails — no empty alt accepted |
image.src | file in src/media, whitelisted | Publish refused (422) |
image.width/height | positive integers | Build fails |
video.provider | youtube or vimeo | Build fails |
video.videoId | 11 characters (YouTube), digits (Vimeo) | Publish refused (422) |
item.id | ^[a-z]-\d{3,}$, unique in its list | Build or publish fails |
| whole file | 100 KB max | Publish refused (413) |
One schema, two uses
The Zod schema lives in inline-core/src/schema.ts, a file that does
not import astro:content. That is the condition for the write
function — which runs outside the Astro context — to import the same object as the
build.
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { pageSchema } from 'inline-core/schema';
const pages = defineCollection({
loader: glob({ pattern: '**/*.json', base: './src/content/pages' }),
schema: pageSchema,
});
export const collections = { pages };Content that would fail the build cannot enter the repository: the function refuses it before writing. There is no state in which the site stops building because of something the client published.
Extending the model
Adding a field type (a data table, a PDF, a code block) happens in schema.ts,
therefore in the shared package, therefore for every site. Three consequences to weigh first:
- it needs a rendering component, an editing gesture in the overlay, and a CSS class if the field has variants;
- server-side sanitising must know what to do with it;
- any change that would invalidate already-published content is a major version, even if the code compiles — it is the client's content that breaks, not ours.
Placing these fields in a page: Adding a page.