Chapter 08Build
Adding a page, a block, a list
From the JSON file to the component on the page. The three editing components, their props, and the hydration rule you must never break.
The page template
A single route file builds every page, in every language:
src/pages/[lang]/[...slug].astro. getStaticPaths decides which URLs
to produce.
---
import { getCollection } from 'astro:content';
import { DEFAULT_LOCALE, LOCALES, localePath, mergeWithDefault } from '../../lib/locales';
export async function getStaticPaths() {
const entries = await getCollection('pages');
const byId = new Map(entries.map((entry) => [entry.id, entry]));
// The page list comes from the reference language: it is the authority.
const pages = entries
.filter((entry) => entry.id.startsWith(`${DEFAULT_LOCALE}/`))
.map((entry) => entry.id.slice(DEFAULT_LOCALE.length + 1));
return LOCALES.flatMap((locale) =>
pages.map((page) => {
const reference = byId.get(`${DEFAULT_LOCALE}/${page}`);
const translation = byId.get(`${locale}/${page}`);
const { data, untranslated } = mergeWithDefault(reference.data, translation?.data);
return {
// “home” is the root of its language: no extra segment.
params: { lang: locale, slug: page === 'home' ? undefined : page },
props: { locale, page, data, untranslated },
};
}),
);
}
---A page that exists only in a translation is not built: the reference language makes the list. And a field missing from the translation is taken from the reference, then flagged — see Multilingual.
Adding a page in four gestures
-
Write the content, in each language
bash bash src/content/pages/fr/services.json src/content/pages/en/services.jsonFile name in lowercase letters, digits and hyphens. It becomes the URL segment:
/en/services/. -
Decide how it renders
If the page looks like the others, the existing template is enough. Otherwise map a layout component to a page name — routing stays in a single file:
src/pages/[lang]/[...slug].astro astro --- import Home from '../../components/pages/Home.astro'; import Services from '../../components/pages/Services.astro'; const TEMPLATES = { home: Home, services: Services }; const { locale, page, data, untranslated } = Astro.props; const Template = TEMPLATES[page] ?? Home; const missing = new Set(untranslated); --- <Base title={data.meta.title} description={data.meta.description} …> <Template data={data} untranslated={missing} /> </Base> -
Place the editable zones
One component per field type. The path is the same as in the JSON.
src/components/pages/Services.astro astro --- import Editable from 'inline-core/components/Editable.astro'; import Media from 'inline-core/components/Media.astro'; const { data, untranslated } = Astro.props; --- <section class="hero"> <Editable data={data} path="blocks.hero.title" as="h1" untranslated={untranslated} /> <Editable data={data} path="blocks.hero.intro" as="p" untranslated={untranslated} /> </section> <Media data={data} path="blocks.hero.visual" widths={[480, 800, 1200]} sizes="(max-width: 48rem) 100vw, 48rem" /> -
Add the navigation link
src/content/site.json,navigationsection. That is structure: the client does not change it from the overlay.
Then:
npm run build && npm run checkThe three components
Editable — text and richtext
<Editable data={data} path="blocks.hero.title" as="h1" />
<Editable data={data} path="blocks.pitch.detail" as="p" class="lead" />| Prop | Type | Role |
|---|---|---|
data | Page | Page data. Not needed if field is given. |
path | string | Path in the JSON, e.g. blocks.hero.title. Required. |
field | TextField | RichtextField | Already resolved field — used by list items, whose path contains an id. |
as | string | Rendered tag. Default: p. |
class | string | Classes added to the style token classes. |
untranslated | Set<string> | Paths taken from the reference language, to be flagged. |
The component applies style classes for a text field, and places the markup as-is
for a richtext one. In both cases the rendering is purely static: the value is in
the raw HTML.
Media — image and video
<Media
data={data}
path="blocks.showcase.visual"
widths={[480, 800, 1200, 1600]}
sizes="(max-width: 48rem) 100vw, 48rem"
/>
<Media data={data} path="blocks.showcase.film" />
For an image, the component uses <Image /> from
astro:assets: AVIF and WebP, a set of widths, dimensions written into the HTML.
For a video it renders a <figure> with the provider iframe and the title —
and the title is in the served HTML.
src/media
Not in public/media. That is the only way for <Image /> to
process them at build time. A file in public/ would be served as-is: no AVIF,
no width set, no dimensions. See Images and videos.
Collection — lists
---
import Collection from 'inline-core/components/Collection.astro';
import Testimonial from '../components/Testimonial.astro';
---
<Collection
data={data}
name="testimonials"
item={Testimonial}
untranslated={missing}
blank={{
quote: {
type: 'text',
value: 'Their feedback, in one sentence.',
style: { size: 'lg', weight: 'regular', italic: true, align: 'left', color: 'primary' },
},
author: {
type: 'text',
value: 'First name L., role',
style: { size: 'sm', weight: 'medium', italic: false, align: 'left', color: 'muted' },
},
}}
/>The item component receives the item and its path:
---
import type { CollectionItem } from 'inline-core/schema';
import Editable from 'inline-core/components/Editable.astro';
const { item, path, untranslated } = Astro.props;
---
<blockquote>
<Editable path={`${path}.quote`} field={item.quote} as="p" class="testimonial-quote" untranslated={untranslated} />
<Editable path={`${path}.author`} field={item.author} as="footer" class="testimonial-author" untranslated={untranslated} />
</blockquote>blank is required
Collection renders existing items and a
<template> holding a blank item, using the same component. The
overlay's “Add” button clones that template: there is no second rendering engine in the
browser to keep in sync. Without blank, the overlay would not know what JSON to
create.
npm run check fails if a collection is rendered without its
<template>: the “Add” button would have nothing to clone.
The attributes placed in the HTML
| Attribute | Carried by | Role |
|---|---|---|
data-cms | the field element | Path in the JSON — the overlay's anchor. |
data-cms-type | same | text, richtext or media. |
data-cms-kind | a media | image or video. |
data-cms-untranslated | a field | Marks a field displayed in the reference language. |
data-cms-list | the list container | collections.{name}. |
data-cms-item | an item | Its id. |
data-cms-template | the <template> | Name of the list it is the model for. |
data-cms-blank | same | The JSON of a blank item. |
data-cms-file | <body> | The page's content file. |
data-cms-locale, data-cms-page | <body> | Current language and page name. |
data-cms-locales | <body> | The site's languages: code, address, label. |
These attributes are inert in production: zero cost, no SEO effect. They only serve the overlay, which is loaded only after authentication.
The hydration rule
This is the project's number one trap, and it is invisible in the rendered source. Astro
accepts React, Vue and Svelte; inline does too, under a condition that is not the
one people expect.
Legitimate
A framework component rendered at build time, with no client:* directive, even for editable content: it produces static HTML, the data-cms ends up in the page, the overlay works on it.
A hydrated island with no editable zone: a carousel, a map, a filter. The carousel must contain all its slides in the markup; JavaScript only scrolls them.
Forbidden
client:only on content: nothing is rendered at build time, the text is in no served HTML, therefore in no index.
client:load around an editable zone: the content is in the HTML, but the framework re-renders it when the JavaScript arrives — wiping the client's edits in progress. That is the more constraining of the two, and it is invisible when reading the page.
check-html.mjs enforces exactly that rule: it verifies that every JSON value is in
the raw HTML, and that no editable zone sits inside a hydrated island.
What the build refuses
| Error | Message | Cause |
|---|---|---|
| Missing path | [Editable] Le chemin « … » n'existe pas dans le contenu. | A typo, or a field missing from one language's JSON. |
| Wrong type | [Editable] Le chemin « … » est de type « media » | A media rendered with Editable instead of Media. |
| Missing image | [Media] Le fichier « … » est absent de src/media. | The JSON references a file that was never copied. |
| Duplicate id | [Collection] Identifiant en double dans « … » | Two items share an id: DOM/JSON reconciliation would break. |
| Schema | Zod report, path by path | A value outside an enum, an empty alt, an over-long title. |
Those failures are deliberate. A component that displayed “(missing field)” instead of failing would let an incomplete page reach production, and nobody would see it before the client does.
After adding a page
- The file exists in every declared language.
npm run buildpasses.npm run checkpasses — content in raw HTML, language parity.- A single
<h1>on the page, heading hierarchy without skipped levels. - Every list has its
<template>(guaranteed if you go throughCollection). - The navigation link is added to
site.jsonif the page must be reachable. - Images are in
src/media/, lowercase and unaccented.