Sign-ups are closed. Menestrel now powers the sites built by Agora Studio, a web agency based in France.

A CMS for developers: schema in code, content out of the repo

Most CMSes ask you to click your content model together in an admin. Putting it in TypeScript instead changes code review, migrations and multi-site work, and costs you something real.

There is a line running through every CMS, and which side a tool sits on tells you almost everything about who it was built for.

On one side, the content model lives in the admin interface. You log in, click “add content type”, add fields through a form, and the shape of your content is a row in the vendor’s database. Contentful, Storyblok, Directus and Strapi’s UI all work this way.

On the other, the model is a file in your repository. You write it, commit it, review it in a pull request. Keystatic, Tina and we do this.

Both work. They fail differently, and the failures are what you should choose on.

What clicking a model together costs

It is not in code review. A colleague adds a field on a Thursday afternoon. Nobody reviewed it, nobody can see it in a diff, and the first sign is a build failing because a template expects a shape that changed. There is no git log for “when did this field appear and why”.

It does not branch. Your feature branch adds a field. The model is shared, so either you add it to production early and ship code that ignores it, or you add it at merge time and the branch cannot be tested. Vendors sell “environments” as the answer, which is a paid tier that recreates branching badly.

It cannot be copied. Twenty client sites share most of their structure. With a clicked model, site twenty-one means clicking it all again. There is no import, no shared package, no way to improve the pattern once and apply it everywhere.

Nobody can review the whole thing. Ask “what is our content model” and the answer is a screen you scroll through. There is no artefact to read, no file to open on a train.

What putting it in code costs

Being fair, this direction has real drawbacks and they are not always acknowledged.

A non-developer cannot change it. If your client wants a new field, they cannot add it. That is a support request routed to you, and on a Friday it is genuinely a downside. Tools where a project manager can add a field without a deploy solve a real problem.

You need a sync step. The file has to reach the server so the admin can render forms for it. That is a command to run, a token to hold, and one more thing that can be out of date.

Migrations become your problem. Rename a field and existing content has to move. A clicked model usually offers a UI for this. In code you need a migration story, and if the tool does not provide one you are writing scripts.

What it looks like in practice

import { collection, defineConfig, fields, singleton } from '@menestrel/fields';

export default defineConfig({
  project: 'atelier-morel',
  locales: { default: 'fr', others: ['en'] },

  collections: {
    services: collection({
      label: { fr: 'Prestations', en: 'Services' },
      slugFrom: 'title',
      fields: {
        title: fields.text({ required: true, localized: true, max: 120 }),
        summary: fields.textarea({ localized: true, rows: 3 }),
        price: fields.number({ unit: '€', min: 0 }),
        photo: fields.image({ required: true }),
        seo: fields.seo(),
      },
    }),
  },

  singletons: {
    contact: singleton({
      label: { fr: 'Coordonnées', en: 'Contact details' },
      fields: {
        phone: fields.tel(),
        email: fields.email(),
        address: fields.textarea(),
      },
    }),
  },
});

Four things follow from this being a file.

It diffs. A pull request adding a field shows exactly that, reviewable in ten seconds.

It branches. The model travels with the code that uses it. A branch adding a field and the template consuming it is one coherent change.

It composes. Common fields become a shared module imported by twenty sites. Improve the pattern once, apply everywhere.

It types. The loader derives the collection schema from the model, so entry.data.price is a number in your editor without you restating it in Zod. The single biggest source of drift, declaring the shape twice, disappears.

The migration question, honestly

Renaming a field is where schema-as-code gets uncomfortable, because the file changes instantly and the content does not.

The approach that works is treating it like a database migration: append-only, with an explicit migration for anything destructive. Adding a field is safe and applies immediately. Removing or renaming one is a diff the system detects and refuses to apply silently, because the alternative is content vanishing without anyone deciding it should.

What you should test before committing to any schema-as-code tool: rename a field with content in it, and see what happens. If the answer is “the old data is gone”, that is disqualifying.

Where content should not live

The other half of the argument, and the one people find more surprising: the model belongs in your repository, the content does not.

Git-based CMSes put both there, which is coherent and has real advantages, covered in the comparison. Our position is that they are different kinds of thing:

The model is a technical decision with consequences for the code. It belongs in review, in branches, in history.

The content is your client’s words. It changes on their schedule, it is edited by people who will never see a repository, and its history should be a list of publications rather than a list of commits mixed with your refactoring. Restoring last month’s price list should not involve archaeology through a fortnight of your own work.

There is also a practical argument: photos. Content in a repository means images in a repository, and a brochure site can accumulate several hundred megabytes of dead image data in a year, which every clone downloads forever.

The obligation this creates is an export that actually works. If content is not in your repo, you need a command that hands it back in a format your next tool reads. Ours produces markdown and JSON in content-collections format, on every plan including the free one. Any hosted CMS that cannot show you that on demand is holding your content hostage, whatever its marketing says.

The sync step, and how not to hate it

The objection people raise fastest is the sync: if the model is a file, something has to push it to the server, and that is a step that can be forgotten.

It is a fair objection, and the answer is in how the sync behaves rather than in pretending it does not exist.

It should be idempotent and cheap. Running it when nothing changed must be a no-op, detectable without a round trip. Compiling the model to a canonical form and comparing a checksum does that: same checksum, nothing to push, exit immediately. It costs nothing to run it on every deploy, so put it in your pipeline and stop thinking about it.

It should refuse ambiguity. If the diff between the file and the server contains something destructive, the right behaviour is to stop and say which field and what would be lost, not to guess.

Errors should point at the field. A model that fails to compile should say collections.services.fields.slug and a stable error code, not “invalid configuration”.

Once those three hold, the sync stops being a chore. It becomes the same thing as a database migration in a normal deploy pipeline: automatic, boring, and occasionally the thing that stops you shipping a mistake.

What this does to onboarding a new project

The place schema-as-code pays off most obviously is site number twenty-one.

With a clicked model, a new client site means opening the admin and recreating the structure: content types, fields, labels, help text, validation, all of it, by hand, hoping you remember the improvements you made on site nineteen.

With a file, it is a copy, a handful of edits, and a sync. Better still, the parts that genuinely repeat, an SEO group, a contact block, opening hours, can live in a shared module that every site imports. Improving the shared pattern improves every site that adopts the new version, on your schedule, visible in a diff.

That is not a small efficiency. Across a fleet it is the difference between each new client being a fresh setup and each new client being a variation on something you already own.

Which side you should pick

Choose a clicked model if non-developers need to change the structure, if you run one large site with a content team, or if the CMS’s other features are what you are actually buying.

Choose schema as code if you maintain several sites, if you want the model in review, or if the drift between “what the CMS says” and “what the templates expect” has bitten you before.

For an agency running Astro sites, the second is usually right, and the deciding factor is rarely elegance. It is that site twenty-one takes an afternoon instead of a week, because the model is a file you copy rather than a form you fill in again.

If you want to see it before deciding, npm create menestrel@latest puts a working project together in one command, and the field reference documents all fifteen types.

Back to the blog