Contentful vs. Storyblok: Who Composes the Page Decides Everything
Both are API-first headless CMSs. The real split is whether editors arrange pages themselves -- and what that costs your component layer.
Contentful and Storyblok are both API-first headless CMSs that put structured content behind one API for many front ends. Contentful is the enterprise default — the deepest integration ecosystem and the longest procurement track record. Storyblok starts from the editor, with a visual in-context page builder on a headless core. Pick by who edits, not by feature grid.
The feature lists overlap enough to be useless. Both do content modelling, both have a REST and a GraphQL layer, both have a visual editor now, both have localisation and webhooks and preview environments. The decision is upstream of all of that: who holds the content, and who is allowed to change a page without a developer. That answer determines which one is cheap to live with.
The comparison that matters
| Contentful | Storyblok | |
|---|---|---|
| Starting point | API-first content platform | Visual editor on a headless core |
| Content lives in | Contentful's content delivery network | Storyblok's Content Lake |
| Content modelling | Fully custom content types and fields | Components ("bloks") composed into stories |
| Editor's mental model | Entries and references in a form | A page, previewed as it will render |
| Who composes a page | Developer builds the layout; editor fills fields | Editor composes and reorders blocks directly |
| Field-level commenting | Not on the free tier | Standard across plans |
| Primary strength | Multi-brand governance, integrations, procurement | Marketing teams shipping pages unaided |
| Primary cost | Editors file tickets for layout changes | A component library you must keep disciplined |
| Fetch shape | Query entries, resolve references yourself | Fetch a story tree, render blocks recursively |
| Vendor risk | Export via API | Export via API |
The row that decides most projects is "who composes a page." Contentful gives editors a form; the arrangement of the page is code. Storyblok gives editors the arrangement too. That is either the feature you are buying or the thing that will wreck your design system, and which one depends entirely on whether your editors outnumber your developers.
Content modelling is where the two actually diverge
In Contentful you define content types, then reference them. A landing page is an entry with fields, some of which are references to other entries. The front end asks for the entry, resolves the references, and decides how to lay it out. The CMS has no opinion about rendering.
// Contentful — entries and references, laid out by code
const page = await client.getEntry(id, { include: 2 });
// `page.fields.sections` is an array of referenced entries. What each
// one LOOKS like is a decision the front end owns, not the CMS.
page.fields.sections.map((section) => renderSection(section));
In Storyblok the content is the tree. A story carries nested bloks, and each blok maps to a component you have registered. The editor adds, removes and reorders them, and the front end renders whatever arrived.
// Storyblok — a story tree, rendered recursively
const { data } = await storyblokApi.get(`cdn/stories/${slug}`, {
version: "published",
});
// `data.story.content.body` is an ordered array of bloks the EDITOR
// arranged. The front end must be able to render any legal order.
data.story.content.body.map((blok) => (
<StoryblokComponent blok={blok} key={blok._uid} />
));
Read those two side by side and the trade is obvious. Contentful's version cannot produce a page you did not design. Storyblok's version can produce a page nobody designed — which is exactly the freedom marketing wanted, and exactly the reason a Storyblok project needs a stricter component contract than a Contentful one. Every blok has to render correctly in any position, at any width, next to anything. That is real engineering work, and it is the hidden line item in the Storyblok column.
The third option, and why this storefront took it
This site's 311 blog posts run on neither. They are MDX compiled at build time, with a typed registry beside them. That is not an argument that headless CMSs are wrong — it is an argument that a CMS earns its keep when non-developers edit, and costs you when they don't.
The wiring is four lines of next.config.ts:
const withMDX = createMDX({
options: {
remarkPlugins: ["remark-gfm"],
rehypePlugins: ["rehype-slug"],
},
});
Those plugin names are strings, not imported functions, and that detail cost real debugging time here. Turbopack runs the MDX pipeline in Rust and cannot receive a JavaScript function reference. Pass the imported plugin and the build succeeds while silently dropping it — which showed up as every comparison table in every post rendering as paragraphs of literal pipe characters, because MDX defaults to CommonMark and CommonMark has no table syntax. A silent no-op is the worst failure mode a build plugin can have, and it is now a comment in next.config.ts so nobody re-learns it.
The second half is src/lib/blog.ts, which pairs each post's metadata with its compiled body through an explicit map — 311 entries, written out slug by slug:
const postLoaders: Record<string, () => Promise<{ default: ComponentType }>> = {
"contentful-vs-storyblok": () =>
import("@/content/blog/contentful-vs-storyblok.mdx"),
"storyblok-vs-tina-cms": () =>
import("@/content/blog/storyblok-vs-tina-cms.mdx"),
// …306 more
};
A wildcard import(`../content/blog/${slug}.mdx`) would be one line instead of 311 and was rejected on purpose: it hands the bundler a glob, pulls every stray .mdx into the graph, and turns a typo into a runtime 500 instead of a failed build. The explicit map makes a missing file a next build type error.
What a hosted CMS gives you for free, this approach has to enforce itself — so it's enforced in tests. src/data/__tests__/blog.test.ts carries 16 invariants, and two of them exist purely to stop the three files drifting:
it("every post has a registered MDX loader", () => { /* … */ });
it("has no loader without a matching post entry", () => { /* … */ });
Others assert that descriptions fit a meta description without truncation, that dates are real calendar dates, and that every relatedTemplates slug references a product that actually exists — so a retired template cannot leave a dead link in a published post. A headless CMS enforces the first kind of rule with required fields and the last kind not at all.
The payoff is in the output shape: MDX compiles to ordinary static HTML. No runtime script, no client-side fetch, no CSP change, and no API that can be down while your marketing site is up. The cost is that editing requires a pull request. For a developer-authored technical blog that is a feature. For a twelve-person marketing team it would be absurd — and that is the whole decision in one sentence.
Build-time vs. request-time is the axis nobody puts in the table
| Contentful / Storyblok | MDX at build time | |
|---|---|---|
| Publish latency | Immediate (API + webhook) | A commit, then a CI build |
| Runtime dependency | The CMS API must answer | None — it's static HTML |
| Non-technical editing | Yes, that's the product | No |
| Preview | Hosted preview environments | Branch deploys |
| Content as reviewable diff | No | Yes, it's a PR |
| Schema enforcement | Required fields in the CMS | Types plus tests you write |
| Cost shape | Seats and API usage | Build minutes |
Both hosted CMSs can be consumed at build time too — fetch at build, regenerate on a webhook — which collapses most of the runtime-dependency row. Do that if you pick one. The storefront's product catalogue uses the same discipline for the same reason: src/data/catalog.ts holds 111 products as typed data, and npm test asserts every referenced screenshot file exists under public/. Static data with invariants is a legitimate CMS substitute at this size; it stops being one the moment someone who doesn't write TypeScript needs to publish.
Mistakes and troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Storyblok page renders blocks in an order your CSS can't handle | Editors can arrange bloks freely; the component assumed a position | Make every blok self-contained — no component may depend on a sibling |
Contentful entries come back with sys stubs instead of content | Reference depth not requested | Pass include: 2 (or higher) on getEntry |
| Visual editor shows stale content | Preview token vs. delivery token mixed up | Use the preview API for drafts, delivery for published |
| MDX tables render as literal pipe characters | remark-gfm passed as an imported function under Turbopack | Name plugins as strings — a function reference is silently dropped |
A post 404s after you added the .mdx file | Loader not registered in postLoaders | Add the entry; npm test catches this before deploy |
| Content model changes break the live site | Field renamed in the CMS, front end not updated | Generate types from the CMS schema and type-check in CI |
| Migrating off either one loses nested structure | Export gives you their tree, not yours | Write the transform first, on a copy, before committing to the move |
Frequently asked questions
Is Storyblok just Contentful with a visual editor? No — the visual editor changes the content model. Storyblok's unit is a composable blok inside a story tree; Contentful's is an entry with fields and references. That difference propagates into how you fetch, how you render, and how strictly your components have to behave in arbitrary positions.
Which is better for Next.js? Both have maintained SDKs and both work with the App Router. Storyblok's visual editor needs a live preview route to render into, which is slightly more setup; Contentful is a plain data fetch. Neither is a reason to choose one.
Can editors break the design in Storyblok? They can produce arrangements you never designed, which is the same thing if your components aren't position-independent. Constrain it in the schema — restrict which bloks nest inside which — rather than hoping editors won't.
When is a headless CMS the wrong answer entirely? When the only people editing are the people writing the code. Then you are paying seats and adding a runtime dependency to get a web form in front of someone who already has a text editor and a branch. This blog's 311 posts and the 111-product catalogue both live as typed files for exactly that reason.
Templates in this post
ASoc Catalyst is an AI-automation agency marketing site — a service grid, a delivery process and 3-tier pricing — the content-dense page where a CMS-backed component library earns its keep. ASoc Chain is a DeFi-protocol site with self-custody messaging and an on-chain 3D hero, the opposite case: a bespoke layout no editor should be rearranging. ASoc Cognition is an AI-consulting landing page with services, pricing, projects and case results — the page type where a marketing team wants to reorder proof sections weekly, which is the editorial workflow both CMSs are actually built for.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
