Engineering · 4 min read
One entry per block, or four sources of truth
A headless CMS gives you a page builder. It also gives you four places to describe the same block, which drift apart within months. Here is the structure we used to stop that, and the bug it did not catch.
Build a page composed of blocks from a headless CMS and you will end up describing each block four times.
Once in the CMS, as a component schema. Once in the frontend, as a TypeScript type. Once in the query, as a population specification telling the API which nested fields to return. Once again in a validation schema, if you are careful enough to validate at all.
Four descriptions of one thing, in two repositories, maintained by hand. They agree on the day you write them. They do not agree three months later, and the way you discover this is a block that renders with a missing image on a page nobody checked.
The failure has a shape
Someone adds a link to a block. They update the CMS schema and the component. They forget the population specification, so the API never returns the link. The component renders nothing where a button should be — no error, no warning, just an absence. It works locally because their local API returns more than production's does.
Or: someone removes a field. The type still declares it. Every consumer still reads it. It is `undefined` now, and the first sign is a page that says "undefined" to a customer.
These are not exotic bugs. They are the normal consequence of one concept having four representations.
One entry, four facts
We collapsed the four descriptions into one registry entry per block:
```ts 'blocks.cta': defineBlock({ kind: 'cta', component: Cta, schema: ctaBlockSchema, populate: { links: true }, variants: ['default', 'quiet', 'full'] }) ```
Three things fall out of this.
**The page query is derived from the registry.** Nobody writes a population specification by hand. A block cannot request data it did not declare, and cannot declare data it does not populate, because both come from the same object.
**`defineBlock` is generic.** The schema's output type and the component's props type are tied together at the type level, so a component whose props do not match its schema fails to compile. Not a lint warning — a build error.
**Variants are tested against their own schema.** The registry declares which variants exist; the schema declares an enum. Those are two statements of the same fact, so a test walks every block with every variant it declares, asserts each is accepted, and asserts an undeclared one is rejected. Add a variant to one and not the other and a test fails.
The bug this did not catch
We were pleased with all of that, and then a different test found a block silently missing from the API query.
The population specification for our rich-text block was `{}` — an empty object, because it has no nested relations to populate. The registry-derived test compared JavaScript objects and passed happily. But the query is serialised with `qs`, and **`qs` omits empty objects**. The block's identifier never appeared in the query string. Strapi was never told to populate it.
The object was right. The string sent over the wire was wrong. Our test was checking the wrong layer.
The fix was small — blocks with no relations serialise as `populate=*` — but the lesson was not. Assert against the artefact that actually leaves your process. We now assert on the serialised query string, because that is what the API receives, and it is the only representation that matters.
Failing safely
The other half of this is what happens when content is wrong.
Every block is validated at the backend-for-frontend before it reaches a component. A block that fails validation is **dropped and logged, never thrown**. An unknown component type is skipped the same way. The rest of the page renders.
This matters because the people composing pages are not the people who can debug a stack trace. An editor saving a half-finished block should not be able to take down a page. They should see their block missing, which is annoying and obvious and fixable, rather than a white screen, which is alarming and opaque.
We also learned to keep the diagnostics on the server. Our first implementation returned the list of dropped blocks to the browser, which meant internal component names and validation paths were sitting in the page source for anyone to read. It rendered correctly and leaked quietly. Now the drops are logged server-side and the client is told nothing.
What this costs
A registry is indirection. A newcomer reading a page template cannot see which components will render — they have to go and look at the registry. That is a genuine loss of legibility and we accept it, because the alternative is legibility that lies.
The other cost is discipline at the boundary: nothing outside the registry may reach a component, which means the transformation layer has to be complete. That is more code than passing the API response straight through. It is also the only reason the CMS is replaceable later without touching a single component.