The registry manifest
Your site publishes a small JSON file saying what it can render. The CMS reads it and refuses to publish content your site would fail to draw.
The file
Serve it as a static asset at the root of your site:
GET https://your-site.example/cms-registry.json{
"revision": "ee8ecd93e252",
"site": "your-site",
"blockTypes": ["brand-hero", "feature-grid", "cta-banner"],
"cutoverRoutes": ["/privacy", "/terms"]
}| Field | What to put in it |
|---|---|
revision | Any string that changes when the other fields change. A hash of them works. |
site | Your site id, so a manifest cannot be read into the wrong project. |
blockTypes | Every block type you have a component for. |
cutoverRoutes | Routes with no coded page left, which therefore depend entirely on the CMS. |
blockSchemas | Optional. What a field VALUE has to be for your site to use it. See below. |
Generate it
Do not write it by hand. Derive it from the registry your app actually uses:
// scripts/cms-registry.mjs
import { writeFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { registry } from '../src/cms/registry.js'
import cutover from '../src/router/cutover-routes.json' with { type: 'json' }
const blockTypes = Object.keys(registry).sort()
const cutoverRoutes = cutover.routes
writeFileSync(
'public/cms-registry.json',
JSON.stringify({
revision: createHash('sha256')
.update(JSON.stringify({ blockTypes, cutoverRoutes }))
.digest('hex')
.slice(0, 12),
site: process.env.VITE_CMS_SITE_ID,
blockTypes,
cutoverRoutes,
}),
)Run it as part of your build. A hand-written manifest drifts from the code, and the day it drifts is the day content publishes that your site cannot render.
Field schemas
blockTypes answers "can you render this block?". blockSchemas answers the question one level down: "can you use this value?"
It exists because of a real incident. A block's field looked, in the editor, like the list of products on the page. It was not: the site rendered from a catalogue in its own code and read each row only as a copy override, matched by slug. A row whose slug was not in the catalogue was never read by anything. So an editor added a tile for a new product, saved, previewed, and the page was byte-for-byte identical. No error, no warning, nothing in the release diff. The change was simply discarded, and finding out why meant reading the site's source.
Any block whose fields reference data in your own code has this failure mode, and it is always silent. Declaring the data is what ends it.
{
"blockSchemas": {
"product-wall": {
"fields": {
"tiles[].slug": {
"required": true,
"enum": ["ui", "verba", "keystone", "stagewright"],
"hint": "A row is only a copy override on a catalogue entry, matched by slug.",
"visible": {
"enum": ["ui", "verba", "stagewright"],
"hint": "That product is not out yet: its page is not live in this build."
}
}
}
}
}
}The key is a field path. [] marks a repeatable, so tiles[].slug means "the slug of every row of tiles", and each row is judged on its own.
| Claim | What it means |
|---|---|
enum | The values you can match at all. Outside it, the value is never read. |
required | The row is inert without this field, so an empty one is a fault. |
hint | Your own words. The editor shows them verbatim, so write them for an author. |
visible | The subset of enum that will actually be drawn as things stand. |
Validation and explanation are not the same thing
This is the distinction visible exists for, and it is the useful half.
A value outside enum is a defect. The editor's work is discarded, so the console warns at the field on save and the release gate refuses to publish it.
A value inside enum but outside visible.enum is correct content you are deliberately withholding: a card for a product whose page is not live yet, say. That is a guarantee your site keeps on purpose, not a fault. It never blocks anything, anywhere. It is declared so the editor can be told "this will not appear yet, and here is why" instead of being left to wonder why their card vanished. A schema that could only say valid or invalid would have to call that an error and refuse a release over content that is exactly right.
Where the claims are enforced
| What happens | |
|---|---|
| On save | Every problem is reported at the field. None refuses the write. A draft publishes nothing, and a save that bounces mid-edit teaches people not to save, which loses more work than a bad value ever could. |
| At the release gate | Every error refuses, exactly as an unrenderable block type does. A release is where someone claims the content is ready. Notes pass. |
Generate these too
The enum in the example above is the product catalogue, and visible.enum is "which of those have a live page in this build". Both come from the code that renders. A hand-written list of slugs is wrong the first time a product ships, which is the drift this whole file exists to prevent one level up.
It is optional, in both directions
Omit blockSchemas and nothing changes: it means "this site makes no field-level claims", never "nothing is valid". No problems are reported and no gate verdict moves, so an existing site keeps publishing exactly as it did.
In the other direction, a CMS that predates this reads the members it knows and ignores the rest, so you can start emitting blockSchemas before every CMS has learned to read it. A claim key the CMS does not recognise is ignored rather than rejected, for the same reason.
Malformed claims are dropped, not fatal
A claim the CMS cannot parse is discarded and named in the refresh response. Refusing the whole manifest over one bad enum would take the block-type gate down with it, which is strictly worse.
Registering it with the CMS
The CMS pulls the file. In the console, open Releases and use the refresh control beside the registry badge. It fetches your production URL and stores the manifest with its revision.
Do this after any deploy that adds or removes a block type. If the site has deployed since the manifest was last read, the console flags it as stale.
Publishing is refused with no manifest
Not "warned about" — refused. Without one, the CMS cannot tell whether your site would paint "Unknown block type" on a live page, so it declines to guess.
Cut-over routes
When you migrate a page to the CMS you usually delete the component that used to render it. From that moment the route has no fallback: if the CMS stops publishing a document for it, the URL resolves to nothing.
Only your repository knows which files are gone, so you declare them:
{ "routes": ["/privacy", "/terms", "/contact"] }Two things then become impossible:
- A release that would leave one of these routes without a renderable document cannot be published.
- Your own build can refuse to ship if the CMS has no document for one of them.
The second is worth adding, and it is a few lines:
const { routes } = await fetch(`${API}/api/v1/${SITE}/${ENV}/_routes`).then((r) => r.json())
const published = new Set(routes.map((r) => r.route))
const missing = cutoverRoutes.filter((r) => !published.has(r))
if (missing.length) {
console.error(`No published document for: ${missing.join(', ')}`)
process.exit(1)
}Adding a block type
- Model the block type in the console.
- Write the component and add it to your registry.
- Deploy. The manifest now lists it.
- Refresh the registry in the console.
- Publish content that uses it.
Steps 3 and 5 are in that order because content naming a component you have not shipped would render as an error on the live site. Until the code is out, the CMS will not let that content publish.
To work on both halves at once, see Preview.