WPEscape

Migrating a Client Project from Sanity to Storyblok

Published August 13, 2026 · 8 min read

We moved a client project from Sanity to Storyblok. A booking site for family courses, Next.js frontend, payments through Stripe. Same content model, same frontend, two very different systems underneath.

This is what the move cost and what it bought. Four fields we deleted and haven't missed, two things that got measurably worse, six traps that ate an afternoon each, and one finding about free plans that we weren't looking for and won't forget.

Why we moved at all

The Sanity setup worked. Schema, queries, embedded studio, seed data, all finished and green. We replaced it anyway.

The reason wasn't technical. People coming from WordPress have learned one thing above everything else: I click the text I want to change. Sanity Studio is a very good form-based back office, but a back office is exactly what it is. You fill in fields, save, then go and look at what came out. Storyblok's Visual Editor puts the real page in an iframe, and clicking a headline opens the field behind it.

For a developer that's a detail. For a client who's meant to maintain her own site, it decides whether she does it herself or picks up the phone.

The timing was the lucky part. The CMS still held nothing but placeholders, so there was no content export, no field mapping, no chance of losing anything on the way. If a CMS switch is anywhere on your list, do it before the first real content lands. Afterwards a day of work turns into a week.

The finding we weren't looking for

Writing the data protection concept raised a question: can the booking data live in the CMS? Names, addresses, a child's date of birth, allergies.

Answering it led to a line in Sanity's docs I'd never read:

"Unlike public datasets, which anyone can query, private datasets will require a valid personal or robot token."

On the free plan, datasets are public. Public means what it says. Anyone who knows the project ID can query the published content. No token, no login. Private datasets are a paid feature and start on the Growth plan.

There's a second detail, which the docs carry as a warning: when a trial ends, private datasets go back to being public. The protection is attached to the subscription, not to the data.

Storyblok works the other way round. From its API docs:

"API requests must be authenticated by providing an API access token as a query parameter."

No token, no answer, free plan included. Closed by default instead of open by default.

Two caveats, so this stays fair

For a course description that's already on the public website, a public dataset is a non-issue. Sanity doesn't hide any of this either. It's in the docs and in the pricing, and it's a product decision, not an accident.

The second caveat matters more. Storyblok's token only protects anything while it stays on the server, and plenty of Storyblok integrations hand it straight to the browser because the editor bridge runs client-side. At that point it sits in every visitor's network tab, and it's public too, just harder to find.

In our setup all reads run server-side and the client module is marked server-only. Import it into a client component by accident and the build breaks, instead of the token quietly shipping to production.

The part that applies to both

The real lesson isn't about vendors, it's about where data belongs.

A delivery token is read access to the whole space. It isn't per-record permissions. That's true of Storyblok, it's true of Sanity, and it's true of every other headless CMS I've worked with. There's nowhere in them to say "anyone may read this course description, but only the instructor may read this booking."

So the CMS holds public content and nothing else: courses, dates, cancellation rules, the about page. Bookings live in Postgres with row level security, no exposed data API, and a separate consent for health data that the database enforces with a check constraint. A record with an allergy note and no matching consent can't be written at all.

That split would have been the right call with a private Sanity dataset too. The public free plan just made it obvious sooner.

What actually differs day to day

Content model: code or interface

In Sanity the schema is code. It lives in the repository, goes through review, has a history. In Storyblok the model is built in the interface and stays there unless you go out of your way to export it.

For a one-off site that's fine. For a template you plan to roll out repeatedly it isn't: both the free and the Growth plan include exactly one space, so every client ends up with their own. Recreating components by hand on every fork is the kind of clicking that goes wrong at three in the afternoon. Ours lives in the repo as a components.json and gets pushed into a space through the CLI. What Sanity gives you for free, you have to rebuild on purpose in Storyblok.

Four fields we deleted and haven't missed

Rebuilding the model, four fields fell away because Storyblok does them natively:

  • Slug. Stories have their own. A separate field would have been a second source of truth.
  • Title on course, session and cancellation policy. The story name does the job. Keeping both means typing the same text twice and watching the two drift apart.
  • Alt text. Storyblok stores it as metadata on the asset, so it's maintained once and valid everywhere. In Sanity it had to be repeated per embed.
  • An image component for rich text. Storyblok's rich text takes images as a native node type.

Less model, same functionality. That was the nicest surprise of the whole move.

Two things that got worse

No reverse references. In GROQ, *[_type == "session" && references(^._id)] fetched a course and all its sessions in one request. Storyblok has no equivalent, so the detail page needs a second call that filters sessions by course UUID. References in the other direction come along for free through resolve_relations.

No cross-field validation. "Minimum participants must not exceed maximum participants" was a rule.custom() in Sanity, and it complained right there at the field. Storyblok validates per field only. A rule turned into a sentence in a field description, which stops precisely nobody.

Rich text isn't rich text

Portable Text and Storyblok's rich text are both structured JSON with component mapping, and that's where the similarity ends. Neither the format nor the renderer API carries over, so the renderer got rewritten.

We skipped the official @storyblok/richtext package on purpose. It produces HTML strings, which would have meant dangerouslySetInnerHTML and plain <img> tags instead of next/image, so no image optimisation. Writing a React renderer for ten node types took less time than working around that would have. Unknown nodes pass their children through, so unexpected content shows up as plain text rather than vanishing.

Six things that cost us an afternoon

Number fields want strings. Send a JSON number when creating a story through the management API and it's accepted without a word. Save that same story in the editor and you get a 422: "must be a string with numbers". The person who'd have hit that is the client, not us, and it would have looked like her edit broke something.

One token is one too few. Storyblok has two read tokens. Preview reads drafts and published content, public reads published only. Use the preview token everywhere and unfinished text is kept off the live site by your own code and nothing else. Put the public token in production and it's impossible instead: a draft request there comes back 401. The cost is that the Visual Editor stops working on the live domain, so editing happens on the preview environment.

?_storyblok=1 is not an access check. Anyone can append the parameter that marks an editor request, and then out come the unpublished drafts. Storyblok ships a signature alongside it, sha1("<space_id>:<preview_token>:<timestamp>"), valid for an hour. Verifying that signature is what should decide whether drafts get served.

The editor check cost us our 404s. The check reads searchParams, and under Next.js Cache Components everything that depends on it has to sit inside a Suspense boundary. By the time notFound() ran, the status was long gone: unknown course URLs answered 200 instead of 404. Search engines treat that as a soft 404 and index the page anyway. We split the editor onto its own /preview route and left the public page free of editor logic.

Two cache layers that disagreed. There was an outer one (use cache, one hour) and an inner one (the fetch itself, also one hour). While both meant the same thing, nothing looked wrong. Shorten the outer one for the preview environment and the contradiction shows up: the outer entry expires, the function runs again, and the inner cache hands back the same hour-old response. The client saved a draft and couldn't see it. Cache lifetime belongs to one layer only.

Drafts don't fire webhooks. Storyblok has triggers around publishing (published, unpublished, deleted, moved) and nothing at all for "draft saved". If you run a preview environment, you can't clear its cache with a webhook. Keep the cache lifetime short instead.

What the caching is worth

Storyblok's free plan counts every API call against 100,000 requests a month, so without caching each visitor costs you requests of their own. With a cache layer on the CMS queries, plus a webhook that clears it the moment something is published, we measured two requests per ten page views instead of twenty.

The number of free course places is deliberately left out of that. It comes from Postgres and gets loaded fresh on every request. A page that says "3 places left" when the course is full would be worse than one that shows a placeholder for half a second.

The short version

The move was worth it, but not for the reasons the comparison tables list. Sanity is the stricter, more developer-friendly system. Schema as code, GROQ beats anything Storyblok's REST API offers, and I still miss cross-field validation. Storyblok wins on one point, and it's the point our clients decide on: you click on the page.

The thing I took away has little to do with either of them. A free plan is a price, not a security promise. Before anything personal goes into a system, read the docs on one question: who's allowed to read this, and what does that answer depend on? With Sanity it depends on the subscription. With Storyblok it depends on the token. With both, it doesn't depend on individual records, which is the whole reason personal data belongs in a database instead.

Leaving WordPress and weighing up headless options? Join the WPEscape waitlist and we'll let you know the moment it's ready for your site.