16m left
← Writing
technical16 min read

What I learned building a Storyblok schema

I spent a few months building the content model for an internal project in Storyblok. My company was an early adopter of Storyblok's newer Schema feature, @storyblok/schema, which meant defining the content model as TypeScript files in the repo instead of building it by clicking around in the CMS interface. I started while Schema was still in preview, and this isn't about the docs being thin at the time. It's a record of what we actually ran into, what I learned from it, and how we solved it internally as a team.

If terms like space, story or block are new to you, I wrote a separate primer covering those first: The three words you need to understand Storyblok. This post picks up from there, with Schema specifically.

Schema as code, and why bother

Before Schema, building a content model meant clicking around in the Storyblok UI: create a block in the Block Library, add its fields one at a time, then go add a Blocks field to whichever page's content type is supposed to hold it. It works. It is also invisible to git: no diff, no pull request, no review step, and no automated test can run against it. Storyblok does keep an activity log of who changed a component and when, but not what changed field by field, or why.

That was the pitch for moving to code. Actually getting there was its own problem. Schema was still in beta when we adopted it, and the documentation was uneven: a sneak-peek announcement, a handful of scattered examples, nothing that walked through a real setup end to end. Even where the schema files were supposed to live in a project wasn't obvious. I spent longer than I would like to admit digging through source and half-working examples just to find a definition that actually compiled.

What I eventually settled on: one block per file, bundled into a single schema.ts.

project/
├── blocks/
│   ├── page.ts
│   ├── hero.ts
│   └── feature-card.ts   ← the featureCardBlock below lives here
└── schema.ts

Schema replaces that with code. You define a block, and the pages that use it, as TypeScript, then run one push. It creates the blocks and wires up the pages that reference them in a single pass, instead of you doing each step by hand in the UI. It works the other way too: schema:pull can bring blocks and pages that already exist only in the UI into your repo as code, which matters if you are adopting Schema on a project that predates it, rather than starting from an empty space.

Because a block is just a file, it isn't locked to the space you wrote it in either. That matters most for the components you reuse constantly: a hero or a feature_card looks almost the same on every project, so instead of rebuilding it by hand in every space's UI, you write it once and push it wherever it's needed. Editing works the same way. Change the field in code, then run schema:push, scoped to just that block or across everything, instead of opening each space's UI and repeating the same edit by hand.

Here is what one block looks like in practice, complete:

import { defineBlock, defineField } from '@storyblok/schema';
 
export const featureCardBlock = defineBlock({
  name: 'feature_card',
  is_root: false,
  is_nestable: true,
  folder: blockFolder,
  fields: [
    defineField('icon', {
      type: 'option',
      source: 'internal',
      datasource_slug: 'icon',
    }),
    defineField('heading', { type: 'text' }),
    defineField('content', { ...richTextPreset }),
    defineField('link', { type: 'multilink' }),
  ],
});

The TypeScript layer here is doing more than formatting. Storyblok describes defineField and defineBlock as having strict type checks, and in practice that means a typo in a field's type, or handing a field a property it does not actually support, gets flagged by your editor before you run anything, instead of failing, or worse, silently accepting the wrong config, after a push.

The question that started the scripts

Once the schema was actually running, a different problem showed up fast. A teammate asked me something simple: how do we know if anything changed? Nothing about Schema answered that on its own. Someone could edit a field in the UI, or another branch could already have pushed something, and there was no way to see that from outside, you found out once something did not match what you expected, usually after it was too late to matter. That question is what the three scripts below were actually built to answer.

The solution I landed on was three small terminal commands (npm scripts), one for each direction of that question, wired up to talk to Storyblok's Management API, the part of Storyblok that lets code create, edit and delete things in a space, rather than just read what's already published:

npm run schema:diff   # what is different?      reads only
npm run schema:push   # my code -> Storyblok    the only one that writes
npm run schema:pull   # Storyblok -> my code    writes local files

It looks like git. That resemblance is the challenge, and it is exactly what caught me out. Before I get into what actually went wrong, here is what you would need if you wanted to set this up yourself and follow along.

Getting it running

It took me longer to get a first read-only run working than I expected, mostly because of the token. The short version of what it takes: Node 22, a Storyblok space, a personal access token with read and write on Components, Datasources and Datasource entries, and @storyblok/schema pinned to exactly the version the project was on, this was pre-1.0 at the time, so a minor release could break defineBlock or defineField out from under us without warning.

Two environment variables, STORYBLOK_SPACE_ID and STORYBLOK_OAUTH_TOKEN, go in a .env file (a plain text file for config values that never gets committed to git) at the project root. The one thing that cost me real time: the scripts read .env themselves, but anything already exported in your shell wins over the file, so a stale value left over from a previous project can sit there looking like a bug in the new one.

Once that was all in place, schema:diff gave me a clean read: the space ID on the first line, then a short report of what differed. More on that report, and the tool it grew into, below.

With that working, here are the challenges I actually ran into, how I fixed each one, and what I learned along the way.

Challenge one: a push sends everything, not just what changed

I assumed schema:push sent my changes. It does not. It sends my branch's entire selection and overwrites whatever is there.

Which means if a teammate merged a new field to main and my branch predates it, my push does not conflict with their field. It deletes it from the live space, along with any content editors had already typed into it.

The fix that stuck was simple: never push everything at once, even though the tool lets you.

npm run schema:push -- --block=feature_card   # good
npm run schema:push                           # sends every block in the schema, don't

A typo in the block name is safe, which I appreciated: it prints RESULT: NOTHING SELECTED and writes nothing.

From schema:diff to schema:preview

Scoping the push was only half the fix. The actual problem my team hit early on was bigger than one bad push: two of us could be changing the same space at once, one editing in the Storyblok UI, one editing local schema files, and nothing told either of us who was supposed to win. At the time, there was no guidance from Storyblok on which side should be the source of truth, the version everyone trusts as correct, when the two disagreed, so we had to decide that for ourselves and build for it.

The first version I built was just schema:diff: read the space, compare it to the local files, print a report. That is where the +/~/! symbols came from, my own idea, so the team could glance at a report and see what had actually changed without reading a wall of field names:

SymbolCodeSpaceA push would
+has itmissingcreate it
~differsdiffersoverwrite the space
!missinghas itdelete it

Caution: Every ! in the diff report represents work that only exists in the UI. Pushing over it removes it silently, with no confirmation step and no undo. Read for ! before you read anything else in the report.

That third symbol was the one that took me an embarrassingly long time to respect, and it is exactly why schema:diff alone was not enough. It compared local files against the space, but it had no idea what was already on main. Two people could both run a clean diff and still be about to step on each other.

So I rebuilt it as schema:preview: before anyone pushes anything, it checks what has changed in the Storyblok UI against what is already merged into main, so a conflict shows up as a warning before the push, not as a surprise after it. I kept the same three symbols, they had already become the shorthand the team used to talk about changes out loud. Run it, and this is the moment it is actually for:

$ npm run schema:preview

Comparing Storyblok UI to main...

! feature_card

1 conflict found. feature_card was edited in the UI after main's last known state.
Pushing now will overwrite those changes. Review the field before continuing, or pull first.

That warning is the entire point. It is the thing that did not exist when my teammate and I first collided, and now nobody has to find out about a conflict by looking at what just got deleted.

It is not a finished fix. Not everyone on the team checks schema:preview before editing in the UI yet, that habit is still catching on, but everyone runs it before a push now, and the blind overwrites that used to happen with zero warning have basically stopped. I would call it a foundation we are still refining, not a solved problem, but it is the difference between finding out about a conflict from a warning versus finding out from a teammate asking where their field went.

Challenge two: drift is normal, and I kept trying to fix it

The first time I ran schema:diff on main, with nothing of my own changed locally, it still showed a screen of differences. I assumed I had broken something and went looking for my mistake.

I had not. The code is the source of truth by policy, not by mechanism. Nothing stops anyone editing the space directly, and people do, so the two sides drift apart constantly. Seeing differences even when you haven't changed anything yourself is the normal state of the system.

That is genuinely uncomfortable if you are used to git, where seeing no differences means nothing has changed. Here the report is telling you about everyone's drift, not your change. Your change is in there somewhere along with the rest. Learning to read past the noise to find my own lines was a real skill, not a footnote.

My first instinct was to try to fix the drift itself, pull everything, push everything, get the space and the code to agree and stay that way. That does not work, and trying is a waste of time: no amount of syncing keeps two sides in agreement if people can still edit either one whenever they want. The actual fix was not technical at all. It had to be a team decision, everyone agreeing out loud that code was the source of truth and treating a UI edit as something to pull in deliberately, not something the tooling should silently paper over.

Challenge three: the field that exists but never appears

The single most common thing I broke. I would add a field, push it, open the editor, and it would not be there. The push succeeded. The diff was clean. No field.

Storyblok groups fields into tabs, and a tab is itself a field that lists the keys it contains:

defineField('tab_content', {
  type: 'tab',
  display_name: 'Content',
  keys: ['title', 'subtitle', 'body', 'cta_label', 'image'],
}),

A field not named in any tab's keys still exists in the data. It just renders no input. So it is invisible to editors and perfectly present to the API, which is the worst combination for debugging. Every time a field "did not push", it had actually pushed and I had forgotten to add it to the list.

Challenge four: the bug that lies to you quietly

This is my favourite, because nothing errored at all. A filter on one of our listing pages quietly offered the wrong set of options: valid-looking choices, just not the ones an editor actually needed.

The field was reading from the wrong lookup table. Storyblok calls these datasources, reusable key/value lists managed in the space:

 defineField('type', {
   type: 'options',
   source: 'internal',
-  datasource_slug: 'category_type',
+  datasource_slug: 'listing_type',
 }),

One word. Both datasources exist, both are valid, both return a perfectly good list of options. The schema was correct, the push succeeded, the dropdown populated. It was just populated with the wrong things, and only someone who knew what a listing type should be would spot it.

I now check dropdown contents against the space, not just that a dropdown appeared.

What I would tell myself, looking back

On the schema itself

  • Do the read-only run before anything else. schema:diff cannot hurt you, and it is the fastest way to find out your env is pointing somewhere unexpected.
  • I built schema:push myself to solve our team's problem, and its own behavior still caught me off guard. In git, push merges your changes in alongside everyone else's. The tool I wrote does not, it replaces whatever is live with whatever you selected, no merging, and once it lands there is no undo on the live space. Treat every push the same way you would treat deploying straight to your actual website, because with the tool I built, that is exactly what it is.
  • I designed ! into the report myself, and I still almost missed what it meant the first time it mattered. Read for it before anything else. Those are the lines that delete things.
  • When a change does not show up in the editor, the schema is almost never the problem. Check the tab keys first.
  • A clean run is not proof of a correct run. Wrong-but-valid config is the failure mode that survives every check you have, and the only way to catch it is to look at the actual result in the actual editor.

On my team and the process

  • Fixing drift with more syncing does not work. No amount of pulling and pushing keeps two sides in agreement if people can still edit either one whenever they want. The actual fix was a team decision, everyone agreeing out loud that code was the source of truth, not something any script can force on its own.
  • Building schema:preview was the easy part. Getting the team to actually run it was the real work. A script nobody uses does not fix anything. The habit of checking before you edit in the UI took longer to land than the tool itself did.

None of this was in the docs as a warning, while I was going through it. The technical parts are obvious afterwards. The parts about the team never would have been in any docs at all, no documentation tells you how to get people to actually change what they do.

Where this stands now

I wrote most of this while @storyblok/schema was still in preview. On 10 September 2026, Storyblok announced the package's 1.0 release. A few things worth knowing if you are starting today instead of during the beta:

  • defineSchema() replaces the plain object that used to bundle blocks, folders and datasources into a root schema.
  • There is a real validation layer now — validateSchema(), validateStory() and createStoryValidator() check a schema or a story against it without throwing (crashing your script with an error), which did not exist when I was debugging challenge three and four by hand.
  • A push that introduces a breaking change now offers to generate a migration stub (a starter file for the code that updates existing content to match the new shape), rather than just applying the change and leaving you to notice what broke.
  • New type-safe API clients, @storyblok/api-client and @storyblok/management-api-client, can derive types straight from the schema with withTypes<Schema>(), so the type safety extends past the schema definition into the code that reads the content back.

Our own scripts still work, we have not thrown them out. But now that Schema is stable, we are actively adapting our commands and process toward the official ones instead of maintaining our own indefinitely. Lining them up side by side was oddly validating:

What we builtStoryblok's own equivalent
schema:pullstoryblok schema init --space <id>
schema:diff / schema:previewstoryblok schema push --dry-run
schema:pushstoryblok schema push

(--dry-run previews what a push would do without actually applying it, same idea as our schema:diff.)

The gap we found ourselves patching by hand turned out to be close to the exact gap Storyblok closed.

The UI-versus-local conflict, the exact thing schema:preview exists to catch, got better too: a push after someone has edited in the UI now errors instead of silently overwriting. Storyblok's own docs go further, recommending the schema as the single source of truth outright, with the Block Library and Datasources UI sections retired once you adopt it. Their suggested enforcement is the permissions system, though custom roles and granular permissions are usually a paid-tier feature, worth checking before you promise your team the UI is locked down.

That does not touch challenges two, three or four, drift, tab keys and the datasource mix-up are all still just how the tool works. But the case for schema-as-code is stronger now than when I started: reviewable, type-safe, versioned like the rest of the codebase. If you were waiting for it to stop being a beta, that reason is gone.

The part I would not trade

Looking back, I genuinely enjoyed the whole mess of it: the digging through half-working examples to find a setup that actually worked, the conflict that finally forced a real conversation about source of truth, building a tool by hand and then watching the team actually adopt it. That part does not show up in any changelog, and it is the part I would not trade for having had a clean, fully-documented beta from day one.

If I am honest, writing this post down mattered almost as much as building the tool did. Explaining schema:preview clearly enough for someone else to trust it forced me to understand our own conflict better than building it ever did on its own. That is the part of this work I keep getting pulled back to, not just closing the gap, but writing it down well enough that the next person does not have to find it the hard way.

Get notified when I post

No spam, just a note when something new is up.