The schema is a migration, not a screen

It is faster to add a field by clicking one into existence in the CMS. It is also the change nobody can review, reproduce, or roll back.

Kash Sajadi

Every hosted CMS gives you a schema editor: a screen where you add a field, pick its type, set a validation rule and save. It is a good screen. Using it is the wrong default, for the same reason clicking a column into a production database is the wrong default.

What clicking costs you

A field added through the UI has no diff, no author, no message and no order relative to the code that depends on it. Three specific things break:

  • Review. Nobody can comment on a change that exists only as a state you have already applied.

  • Reproduction. Forking a fresh environment to try something risky gives you a copy of today's schema, not a schema you can rebuild from scratch.

  • Ordering. The template that renders a field and the field itself land at different times, so there is always a window where the site is broken and it is not obvious why.

The last one is the one that actually bites, and it is the one people discover at the worst moment.

The content model as code

So the model here is a checked-in migration script, run by the CMS's own CLI. Models, fields, validators and the block types the article body can embed are all created in one file that lives beside the components that read them.

const post = await client.itemTypes.create({
  name: 'Post',
  api_key: 'post',
  // The whole reason the preview host exists: this is what gives
  // a post a draft version distinct from its published one.
  draft_mode_active: true,
})

Note the comment. The interesting thing about that flag is not what it does, it is what depends on it — an entire second deployment target exists because posts have drafts. A screen in a web app has nowhere to put that sentence. A file does.

Start small on purpose

The model is deliberately thin. Posts, authors, categories, and two block types for the article body. No tags, no series, no related-post picker, no reading time.

Not because those are bad fields, but because a content model designed before anyone has written ten posts is a set of guesses about how people will write. Guesses that ship as required fields are expensive: every author pays for them on every post, forever, and removing a field is harder than adding one.

Code snippets are the example we did get right by accident. The obvious move was a code block type. The editor already has a native code node, so a block would have been a worse version of something that existed. We only found that out by not building it first.