Skip to content
All posts
3 min read

Content modelling is API design with worse feedback

A CMS schema is a contract between engineers and editors. The difference is that when you get an API wrong, tests fail — when you get a schema wrong, people quietly stop using it.

  • cms
  • architecture
  • content-modelling

I've inherited a lot of content models. The broken ones fail in a consistent way, and it is never the way the ticket describes.

The ticket says the CMS is slow, or confusing, or that editors keep asking engineers for help. The actual problem is nearly always that the schema was modelled after the page designs instead of after the content, and the design changed.

The symptom you'll recognise

A field called text_2. Or hero_variant_b. Or a boolean called use_new_layout that three content types check independently.

Each of these is a schema that got a requirement it couldn't express and grew a workaround instead of a model. They accumulate. Eighteen months in, nobody can say what a page document means without reading the template that renders it — which is exactly the coupling a headless CMS was supposed to remove.

Model the content, not the composition

The useful discipline is to ask what an editor is saying, independent of where it appears.

A "hero" is not a content type. It's a layout decision. What the editor is actually providing is a headline, a supporting sentence, and a destination. That triple might render as a hero today, a card tomorrow, and an email module next quarter. Modelled as hero, the redesign breaks it. Modelled as what it is, the redesign is a template change.

This sounds abstract until you apply the test: if the design were thrown away entirely, would this field still make sense? Fields that fail that test are usually layout in disguise.

Constraints belong in the schema

The second failure is treating validation as documentation. A note in Confluence saying headings should stay under 70 characters is not a constraint. It's a wish.

schemas/announcement.ts
defineField({
  name: "heading",
  type: "string",
  // 70 chars is where this wraps to three lines at 320px. Enforced here,
  // because the alternative is finding out in production on a Friday.
  validation: (rule) => rule.required().max(70),
})

The comment matters as much as the rule. An editor who hits a limit with no explanation assumes the system is being arbitrary and asks an engineer to remove it. An editor who knows the limit exists because of small screens works within it.

Relationships, not duplicated strings

Storing a URL as a string is the single most common source of broken links I've seen in production CMS content. Storing a reference to a route document means the URL is derived, a moved page updates every link to it, and a link to a deleted page cannot be published — the CMS refuses.

That's not a nicety. It's the difference between link rot being inevitable and being impossible.

The feedback problem

Here's what makes this harder than API design. When an API contract is wrong, something fails loudly: a test, a type check, a 500. When a content model is wrong, the failure is social. Editors find the model confusing, quietly go back to asking engineers for changes, and the CMS becomes an expensive database that one team knows how to use.

Nobody files that as a bug. It shows up a year later as "we should look at replacing the CMS", when the CMS was never the problem.

The only reliable countermeasure I've found is to watch someone edit. Not ask them — watch. Every field they hesitate over is a naming problem, and every field they leave empty is either unnecessary or unclear. Twenty minutes of that is worth more than any amount of schema review among engineers, because engineers are not the users of this contract.