Storyblok and Next.js: the complete setup in 2026

What Storyblok actually is, the five primitives every Next.js integration runs on, and the full App Router setup — including the bits that bite once real editors start clicking around.
This post is the complete guide to running Storyblok with Next.js — App Router, React Server Components. It opens with what Storyblok actually is and where it sits in the 2026 landscape. Then it walks through the core primitives — Bloks, Stories, the Visual Editor, the Bridge, the CDN...
The introduction of Storyblok CMS
Storyblok is a cloud-native, component-based headless CMS. That sentence does a lot of work — unpack it.
Headless means the CMS gives you content as JSON over HTTP and doesn't care what renders it. No themes. No Liquid templates. No PHP. Bring your own frontend — Next.js, Astro, Nuxt, Svelte — and call the Content Delivery API like any other JSON endpoint.
Cloud-native means SaaS. You don't host it. You don't patch it. You don't run a database or page an SRE at 3 AM. The trade-off is real: no raw access to storage, and you live inside the region you pick.
Component-based is where Storyblok diverges. Most headless systems are entry-centric — a document has a title, a body, a few related fields, done. Storyblok's unit is the Blok: a schema defined in the dashboard that maps 1:1 to a UI component in your codebase. Your hero is a Blok. Your CTA is a Blok. Your three-column grid is a Blok. Stories — the pages — are nothing more than a tree of Bloks, each a JSON object with a component name that tells your frontend which React component to render.
That structural choice cascades into everything else — Visual Editor, lock-in profile, migration pain, AI roadmap. Miss it and the rest looks like unrelated features instead of one coherent design.
Where it lives in the 2026 landscape
The headless CMS market settled into four loose tribes.
The enterprise tribe — Contentful, Optimizely, Adobe Experience Manager. Stable, expensive, governance-first. Plans start around $300/month for Contentful; teams report paying 40–60% above headline price once limits bite. Pick these when legal and procurement nod first.
The developer tribe — Sanity, Payload. Schema-as-code, GROQ queries, models in TypeScript committed to Git. Sanity sells at $15/user/month for Growth. Pick these when engineers buy and marketers come second.
The open-source tribe — Strapi, Directus. Self-hostable. Zero vendor lock-in. Self-hosting runs 300 to 1,300 hours per year in patching, scaling, incident response. Pick these when sovereignty or budget outweighs convenience.
The visual-first tribe — Storyblok, Builder.io, Contentstack. Built around live preview, marketer autonomy, page-building velocity. Storyblok's Growth plan is $99/month; Growth+ is $349/month. Pick these when marketers will use the CMS more than developers.
Storyblok wins on a single number: one Forrester study cited 582% ROI for adopters, with Education First cutting global site builds from six months to eight weeks. The DX is fine. The marketer experience closes deals.
Why it survives engineering-heavy stacks
If marketing autonomy were the only story, this would be a marketing-tools post. Two things keep Storyblok in serious Next.js stacks.
The SDK matured. @storyblok/react stabilized App Router support in v5; v6 is the current line. You get typed initializers, server-side fetch helpers, a StoryblokStory container that handles the Bridge automatically, and a StoryblokServerComponent that recursively renders nested Bloks inside RSC trees.
The CLI grew up. Generate TypeScript types from Blok schemas. Version-control component definitions as JSON. Sync schemas across spaces. Run content migration scripts. The schema-as-code purists still complain that the dashboard is canonical, but the gap to Sanity has narrowed.
The result: a CMS that hands marketers a live preview and hands developers typed, server-rendered content with a sensible cache story.
The five primitives
Storyblok has many features. Five primitives carry the weight. If these are clear, the SDK reads like English.
Bloks
A Blok is the schema for a single content type. Define one in the dashboard — fields, types, defaults, validations — and Storyblok generates an editor UI for it. Every instance becomes a JSON object with three reserved fields (_uid, component, _editable) plus your custom fields.
Three flavors:
Nestable Bloks. Reusable building blocks — Hero, Grid, Feature, Card. Composed into pages. Never live at a URL alone.
Content type Bloks. Root-level Bloks that can be a page — page, post, landing_page. They have URLs. This is what people mean when they say "a Story."
Universal Bloks. Platform-shipped helpers — richtext, markdown, asset. Rarely need defining.
In code, a component registry maps each Blok name to a React component:
const components = {
page: Page,
post: Post,
hero_block: Hero,
cta: Cta,
richtext: Richtext,
// ...register every Blok
} as const;
The as const matters — it gives you literal-type inference for Blok payloads downstream.
Stories
A Story is the unit of content that has a URL. Internally: metadata (name, slug, full_slug, timestamps, tag_list) plus a content field holding the root Blok tree.
Stories live in a folder tree inside a Space. The tree drives both editorial UI and URL structure unless you hard-code routes.
Two flavors matter:
- Published. Production fetches with the public token.
- Draft. Visual Editor and previews fetch with the preview token.
The CDN serves both behind separate tokens with different rate limits. The split is what makes draft mode possible — and what makes draft mode dangerous if a cookie leaks into production.
The Space
A Space is Storyblok's word for "project." Container for Stories, Bloks, Assets, Workflows, Tokens. Each Space has a region — EU, US, AP, CA, CN — and that pin is permanent. You cannot move a Space across regions; only export and reimport into a new one.
This matters more than it sounds. Wrong region, developers fight latency forever. Right region, the CDN feels invisible.
Spaces also hold the cv (cache version) parameter — a Unix timestamp written every time anything in the Space changes. Every CDN request is keyed by cv; pass a stale value and the CDN responds with a redirect to the latest cv, quietly defeating any cache you built. This is where caching strategies go to die.
The Visual Editor and the Bridge
The Visual Editor is what Storyblok sells. An iframe-driven WYSIWYG: your live Next.js app renders inside the dashboard, and the editor draws editable outlines, drag handles, and inline controls over your rendered HTML.
The trick is the Storyblok Bridge — a JavaScript runtime loaded inside your app that talks to the dashboard via window.postMessage. The flow:
- Editor opens a Story.
- Dashboard iframes your preview URL.
- Bridge initializes inside your app.
- Bridge announces ready.
- Dashboard sends events —
input,change,published,unpublished. - Bridge fires hooks; your app re-fetches the draft Story or re-renders.
The "live-as-you-type" feature is the killer demo. It also broke when the App Router shipped, and we'll cover the workaround.
The Bridge exposes one critical utility: storyblokEditable(blok). It reads the _editable JSON string Storyblok injects into every Blok response and translates it into data-blok-c and data-blok-uid attributes — the hooks the editor uses for overlays and click routing. Forget the spread and that component is invisible to the editor: clicks pass through, outlines never appear, editors complain about "broken blocks." Single most common silent failure in Storyblok onboarding.
Three Bridge events matter day to day — input fires on every keystroke in the form, change fires when the editor moves between fields, published fires when the Story goes live. The SDK exposes them through useStoryblokBridge(storyId, callback) and a higher-level useStoryblok(slug) hook that handles fetching plus subscription in one call.
The Content Delivery API
The CDN is what your Next.js app actually talks to. REST API at api.storyblok.com (or regional equivalent), with a read-only GraphQL endpoint bolted on. Endpoints you'll use 95% of the time:
GET /v2/cdn/stories/{slug}— a single Story.GET /v2/cdn/stories?starts_with=posts/— listing Stories under a folder.GET /v2/cdn/links— the link map for static generation.GET /v2/cdn/datasources— key/value reference data.
Every request takes a token, a version, and the global cv.
Two things bite in production. Rate limits are bimodal. Single-Story fetches scale to 1,000+ req/sec. Listing requests at 75–100 stories drop to 6 req/sec uncached. Parallel SSG builds hit that ceiling fast. The GraphQL endpoint runs on a 100 points/sec budget where each nested join consumes multiple points. You discover this when your build fails.
Five primitives — Blok, Story, Space, Visual Editor + Bridge, CDN. The rest is dashboard polish and SDK ergonomics.

What's genuinely new
The parts worth knowing that didn't exist twelve months ago.
Strata — vector-based semantic layer on top of existing content. Instead of keyword matching, it understands meaning. Useful for AI agents that retrieve by intent rather than slug. Positioned as the bridge to the "agentic" era.
FlowMotion — workflow automation powered by n8n. Save a Story, fire an event, run a chain: translate to ten locales, ping Slack, generate alt text, trigger a build. Connects to 500+ integrations.
The AI Suite — production-ready. Native AI translations across 30+ languages, AI SEO scoring, AI alt-text in the asset editor. Also a native MCP (Model Context Protocol) Server that lets external AI agents query your content schema and brand context directly.
Content Calendar/Planner — centralized view of planned releases, who changed what, what goes live when. Release merging detects conflicts when two releases touch the same segment.
Subspaces (in development) bring Git-style branching to content models — work on parallel versions, merge back. Inline Editing is on the roadmap: type directly in the preview instead of the sidebar form.
These don't change the setup. They change what Storyblok will mean a year from now.
The complete Next.js setup
What follows is the full walkthrough, App Router first. Patterns here run in production at scale — atomic-design component folders, RSC fetches with cache(), draft mode via cookie, on-demand revalidation via webhook, CSP set for the Storyblok iframe origin.
Before you start: sign up and bootstrap the app
Two ingredients before any code: a Storyblok account and an App Router project.
Sign up at app.storyblok.com. The free tier covers a single space with one editor seat — enough to wire the integration end to end before billing matters. The dashboard will prompt you to create your first Space; defer that to Step 1 so the region choice is deliberate.
Bootstrap the Next.js side with the official CLI:
npx create-next-app@latest my-site
Accept the defaults that matter — TypeScript, App Router, src/ directory, Tailwind if you want it. Don't enable static export unless you know you need it; SSG + RSC + on-demand revalidation is the path the rest of this guide assumes.
When the Space prompt appears in Step 1, you'll see two options: blank space or blueprint (a starter content model, often a marketing site). Pick blank. Blueprints ship Bloks you'll spend an hour deleting; starting empty makes the content model in the next steps deliberate.
This guide has been tested with the following package versions:
- next@16.2.3
- react@19.2.3
- react-dom@19.2.3
- @storyblok/react@5.4.18
- Node.js v22.17.1
Step 1 — Pick your region, create the Space
Create a new Space in the dashboard. Region selection is permanent — pick the one closest to your users. EU hits api.storyblok.com; US hits api-us.storyblok.com; AP hits api-ap.storyblok.com; CA hits api-ca.storyblok.com. Wrong region is a silent latency tax.
Three things matter inside the Space: Settings → Access Tokens, Settings → Visual Editor, and the Block Library.
Storyblok issues several token types and the distinction matters. Public reads published Stories — the production token. Preview reads drafts — the Visual Editor and any preview surface. Asset, Release, and Theme are scoped read-only tokens for specific surfaces. All five are read-only Content Delivery API tokens. Write access lives elsewhere: the Personal Access Token authenticates a single user against the Management API, and the OAuth flow authorizes external apps. For the Next.js setup you need exactly two: one Public, one Preview. Both go into your secret store. Never the public repo.
Step 2 — Configure the preview URL
Under Settings → Visual Editor, set the preview URL to your local HTTPS dev URL (e.g. https://localhost:3000/).
Two non-negotiables.
Local dev must run over HTTPS. The Visual Editor iframe refuses HTTP origins because of mixed-content policies. The fastest path is npx next dev --experimental-https — Next.js generates a self-signed cert on first run and serves over https://localhost:3000. For a trusted cert (no browser warning), mkcert localhost produces cert.pem and key.pem you can pass with next dev --experimental-https-key ./key.pem --experimental-https-cert ./cert.pem.
Your CSP must allow the Storyblok origin as a frame ancestor. Without this, the browser refuses to render your page inside the dashboard's iframe.
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: "frame-ancestors 'self' https://app.storyblok.com",
},
// ...other headers
];
const nextConfig: NextConfig = {
async headers() {
return [{ source: '/:path*', headers: securityHeaders }];
},
};
Skip the CSP and the Visual Editor shows a blank iframe with a console error you can't see — the dashboard hides DevTools. You'll spend an hour on it. Set the header now.
Model the page in the dashboard before writing code
The codebase mirrors the dashboard, so the dashboard goes first.
Open Block Library → New Block and create a content-type Blok called page. Add one field: body, type Bloks, set to nestable. The root holds whatever nested Bloks compose the page — keep it that thin.
Now create your first nestable Blok. New Block → Nestable, name it hero. Add fields: headline (text), subtitle (text), image (asset), cta_label (text), cta_link (link). Save.
In Content, create your first Story. Pick page as the type, name it Home, slug home. Drag a hero Blok into the body and fill the fields. The editor renders an inline form for the schema you just defined.
You now have a content tree the API will serve. Every Blok you add later — Feature, Grid, CTA, Card — follows the same loop: define schema in the dashboard, then register a React component for it in code.
Step 3 — Install the SDK and create the init module
npm install @storyblok/react
That's the core SDK. If you use Storyblok's rich-text field (you almost certainly will), add the renderer separately:
npm install @storyblok/richtext
pnpm and yarn work the same way — pick your package manager.
Now the heart of the setup: a single init module at src/lib/storyblok.ts that registers your component map and initializes the SDK. Imported by both server (RSC fetches) and client (the provider that loads the Bridge).
import { apiPlugin, storyblokInit } from '@storyblok/react/rsc';
import { cache } from 'react';
import Page from "@/components/templates/Page";
import Post from "@/components/templates/Post";
import Hero from "@/components/organisms/Hero";
import Richtext from "@/components/atoms/Richtext";
// ...other imports
const components = {
page: Page,
post: Post,
hero_block: Hero,
richtext: Richtext,
// ...register every Blok
} as const;
export const getStoryblokApi = storyblokInit({
accessToken: process.env.STORYBLOK_DELIVERY_API_TOKEN as string,
use: [apiPlugin],
components,
apiOptions: { region: 'eu' },
});
Four details worth flagging.
Import from @storyblok/react/rsc, not @storyblok/react. The base path is client-only and breaks under RSC. The /rsc subpath gives the server-friendly version. Pages Router imports from the base path. Static export (output: 'export') imports from @storyblok/react/ssr.
Every Blok must be registered. Forget one and StoryblokServerComponent renders nothing for that type. No warning. No error. Just an empty section.
The token name is your call. Storyblok's docs use STORYBLOK_DELIVERY_API_TOKEN — server-only, because a public token is still a token and anyone reading your bundle can hit your Space's CDN. The honest tradeoff: the Bridge needs the token on the client to subscribe to edit events. Two shapes work. Keep the public token server-only and let the Bridge initialize without it (no live-as-you-type for client-side surfaces, but read paths stay server-rendered). Or prefix it NEXT_PUBLIC_* and accept that the public token ships to browsers — fine for content meant to be public anyway, but understand the budget exposure. Whichever you pick, the preview token stays strictly server-only, read inside the draft-mode route handler.
Region is configured once. apiOptions: { region: 'eu' } (or 'us', 'ap', 'ca') wires every CDN call to the right endpoint. Wrong region here and you'll fight latency without knowing why.
Step 4 — Build the folder structure
The shape that scales is atomic-design adjacent:
src/
├── app/ # Next.js routes
│ ├── [...slug]/page.tsx # Catch-all for nested pages
│ ├── api/
│ │ ├── revalidate/route.ts # Webhook receiver
│ │ ├── og/route.tsx # OG image generator
│ │ └── draft/route.ts # Draft mode toggle
│ ├── layout.tsx
│ └── page.tsx # Home
├── components/
│ ├── atoms/ # Cta, Markdown, Media, Richtext
│ ├── molecules/ # Feature, Teaser, Card
│ ├── organisms/ # Hero, Grid, Header, Footer
│ ├── templates/ # Page, Post
│ └── providers/ # StoryblokProvider, ThemeProvider
├── lib/
│ ├── storyblok.ts # Init + component registry
│ ├── storyblok-api.ts # Lightweight init for API routes
│ ├── storyblok-utils.ts # storyblokEditable wrapper
│ └── storyblok-version.ts # Draft/published switch
└── types/
└── storyblok.ts # Generated types
The two extra storyblok-* lib files are not over-engineering.
storyblok-api.ts — lightweight initializer without the component registry. Use it from API routes (OG image, sitemap, RSS) where bundling every React component blows the Edge function past the 1MB Vercel limit. Lighter init, faster cold starts.
const getApi = storyblokInit({
accessToken: process.env.STORYBLOK_DELIVERY_API_TOKEN as string,
use: [apiPlugin],
apiOptions: { region: 'eu' },
});
export async function fetchStoryApi(fullSlug: string) {
try {
const api = getApi();
const { data } = await api.get(`cdn/stories/${fullSlug}`, {
version: storyblokVersion,
});
return data.story;
} catch {
return null;
}
}
storyblok-version.ts — a one-liner returning 'draft' in dev, 'published' in prod. One line because importing the full Storyblok init just to read an env var inside Edge is wasteful.
export const storyblokVersion: 'draft' | 'published' =
process.env.NODE_ENV === 'development' ? 'draft' : 'published';
Each module imports the minimum surface area per runtime. RSC trees get the full init; Edge OG gets the skinny init; the version switch travels alone.
Step 5 — Wire up the root layout with the provider
The Bridge is client-side. The SDK loads it when storyblokInit runs in the browser. You need a client-component provider that re-initializes the SDK on the client.
// src/components/providers/StoryblokProvider.tsx
"use client";
import { ReactNode } from "react";
import { getStoryblokApi } from "@/lib/storyblok";
export default function StoryblokProvider({
children,
}: {
children: ReactNode;
}) {
getStoryblokApi();
return children;
}
That's the whole component. getStoryblokApi() triggers the Bridge load on the client. Without this, the iframe sees your page but the Bridge never initializes — outlines, drag handles, live updates all silently break.
Wire it into the root layout. Pre-fetch global Stories (header, footer) in parallel so they hydrate with the rest:
import StoryblokProvider from "@/components/providers/StoryblokProvider";
import { getStoryblokApi, storyblokVersion } from "@/lib/storyblok";
import Header from "@/components/organisms/Header";
import Footer from "@/components/organisms/Footer";
export default async function RootLayout({ children }) {
let headerStory = null;
let footerStory = null;
try {
const api = getStoryblokApi();
const [headerData, footerData] = await Promise.all([
api.get('cdn/stories/global/header', { version: storyblokVersion }),
api.get('cdn/stories/global/footer', { version: storyblokVersion }),
]);
headerStory = headerData.data.story;
footerStory = footerData.data.story;
} catch (error) {
console.error('Error fetching global components:', error);
}
return (
<html lang="en">
<body>
<StoryblokProvider>
{headerStory && <Header blok={headerStory.content.body[0]} />}
<main>{children}</main>
{footerStory && <Footer blok={footerStory.content.body[0]} />}
</StoryblokProvider>
</body>
</html>
);
}
Fetch global Stories in parallel. Sequential fetches add 200–400ms to every page render. The CDN handles parallelism cleanly.
Step 6 — Write a Blok component the right way
Every Blok component needs three things: typed props, storyblokEditable spread on the root, and StoryblokServerComponent (RSC) or StoryblokComponent (client) to render nested children.
The simplest example — a Page template:
import { memo } from 'react';
import {
makeStoryblokEditable,
StoryblokServerComponent,
} from '@/lib/storyblok-utils';
import type { StoryblokComponentProps, PageBlok } from "@/types/storyblok";
const Page = memo(({ blok }: StoryblokComponentProps<PageBlok>) => {
return (
<main {...makeStoryblokEditable(blok)}>
{blok.body?.map((nestedBlok) => (
<StoryblokServerComponent blok={nestedBlok} key={nestedBlok._uid} />
))}
</main>
);
});
Page.displayName = 'Page';
export default Page;
A wrapper around storyblokEditable lives in storyblok-utils.ts because the SDK signature expects SbBlokData and your typed Bloks don't match without a cast. Hide the cast in one place:
import { storyblokEditable, StoryblokServerComponent } from '@storyblok/react/rsc';
export { storyblokEditable, StoryblokServerComponent };
export const makeStoryblokEditable = <T extends Record<string, unknown>>(blok: T) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return storyblokEditable(blok as any);
};
For a leaf component like a CTA, no recursion — just spread storyblokEditable on the root:
'use client';
import { memo } from 'react';
import Link from 'next/link';
import { makeStoryblokEditable } from '@/lib/storyblok-utils';
import type { CtaBlok } from '@/types/storyblok';
const Cta = memo(({ blok }: { blok: CtaBlok }) => {
const { label, navigate_to, cta_type = 'primary' } = blok;
const href = navigate_to?.cached_url || '/';
return (
<Link {...makeStoryblokEditable(blok)} href={href} className={`btn btn-${cta_type}`}>
{label}
</Link>
);
});
Cta.displayName = 'Cta';
export default Cta;
Spread storyblokEditable on every Blok component. Single most common silent break.
Step 7 — The catch-all route that handles every page
Storyblok stores a flat folder tree. Don't define a route per Story. Define one catch-all that resolves any slug, fetches the Story, and lets the component registry render whatever's there.
// src/app/[...slug]/page.tsx
import { getStoryblokApi, fetchStory, storyblokVersion } from '@/lib/storyblok';
import { StoryblokStory } from '@storyblok/react/rsc';
import { notFound } from 'next/navigation';
export const revalidate = 86400; // 24h fallback; webhooks handle real-time
interface PageProps {
params: Promise<{ slug: string[] }>;
}
export default async function CatchAllPage({ params }: PageProps) {
const { slug } = await params;
const fullSlug = slug.join('/');
const story = await fetchStory(fullSlug);
if (!story) notFound();
return (
<div className="page">
<StoryblokStory story={story} />
</div>
);
}
export async function generateStaticParams() {
const api = getStoryblokApi();
try {
const { data } = await api.get('cdn/links', {
version: storyblokVersion,
});
return Object.values(data.links)
.filter((link: any) => !link.is_folder && link.slug !== 'home')
.map((link: any) => ({ slug: link.slug.split('/') }));
} catch (error) {
console.error('Error generating static params:', error);
return [];
}
}
Two routing shapes both work. [...slug] (above) is a required catch-all and needs a separate app/page.tsx for the home Story. [[...slug]] (double brackets) is an optional catch-all — one file resolves every URL including /, no separate home route. The optional form is what Storyblok's own docs recommend; the required form is cleaner if you want a distinct home rendered differently from interior pages. Pick one and stick to it.
Three patterns worth flagging.
StoryblokStory is the right top-level wrapper, not StoryblokServerComponent directly. StoryblokStory handles the Bridge automatically — knows when a Story is in preview and re-fetches on edit events. StoryblokServerComponent is the recursion primitive used inside Blok components.
generateStaticParams reads the link map, not the Story list. /cdn/links returns all slugs in a flat object, fast. Don't paginate over Stories — you'll hit the listing rate-limit cliff.
The fetch helper uses React.cache(). Inside storyblok.ts:
export const fetchStory = cache(async (fullSlug: string) => {
try {
const api = getStoryblokApi();
const { data } = await api.get(`cdn/stories/${fullSlug}`, {
version: storyblokVersion,
});
return data.story;
} catch (error) {
console.error(`Error fetching story for slug: ${fullSlug}`, error);
return null;
}
});
React.cache() dedupes calls inside a single request — if generateMetadata and the page body both call fetchStory(fullSlug), you pay once. Without it, your build doubles.
Step 8 — Draft mode and the preview cookie
Draft mode is Next.js's built-in mechanism for showing unpublished content. The flow:
- Editor opens a Story in Storyblok.
- The iframe loads your preview URL with a
_storyblokquery param. - Your app hits
/api/draftwhich callsdraftMode().enable()— sets an HTTP-only cookie. - Subsequent fetches check
draftMode().isEnabledand switch to the preview token +version: 'draft'.
The draft route:
// src/app/api/draft/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import type { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug') || '/';
if (secret !== process.env.STORYBLOK_PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 });
}
const draft = await draftMode();
draft.enable();
redirect(slug);
}
The fetch helper checks draft mode:
import { draftMode } from 'next/headers';
export async function fetchStory(fullSlug: string) {
const { isEnabled } = await draftMode();
const version = isEnabled ? 'draft' : 'published';
const api = getStoryblokApi();
const { data } = await api.get(`cdn/stories/${fullSlug}`, { version });
return data.story;
}
The cookie persists. Once enabled, it stays on until explicitly disabled. An editor who navigates from preview to the live site keeps seeing unpublished content. Production needs an "Exit Preview" toolbar that hits /api/draft-exit to clear the cookie. Skip it and editors will report "production is broken" three hours after they forgot to exit preview.
Step 9 — The "live-as-you-type" workaround
React Server Components have no local state. They can't subscribe to Bridge events. So when an editor types in the dashboard, the iframe doesn't update — they have to save first, killing the instant feedback the Visual Editor sells.
The workaround: a Server Action that calls revalidatePath() on every input event from the Bridge, forcing Next.js to re-fetch and stream updated HTML back to the iframe.
// src/lib/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function revalidateStoryAction(slug: string) {
revalidatePath(`/${slug}`);
}
A client component subscribes to Bridge events and fires the action:
'use client';
import { useEffect } from 'react';
import { useStoryblokBridge } from '@storyblok/react/rsc';
import { revalidateStoryAction } from '@/lib/actions';
export function StoryBridge({ storyId, slug }: { storyId: number; slug: string }) {
useEffect(() => {
const bridge = useStoryblokBridge(storyId, () => {
revalidateStoryAction(slug);
});
return () => bridge?.()?.destroy?.();
}, [storyId, slug]);
return null;
}
For client-rendered surfaces (a dashboard widget, a preview-only page), the useStoryblok(slug) hook is simpler — it handles the fetch and the Bridge subscription in one call, returning the current Story and re-rendering on edits. Use it when the surrounding tree is already client-side; stick with the Server Action pattern above when RSC is doing the work.
More friction than the Pages Router. The trade for RSC caching and streaming is that you manually trigger revalidation. Works, but not as seamless as the old getStaticProps flow.
Step 10 — On-demand revalidation via webhook
Storyblok fires webhooks on publish, unpublish, delete, and move events. Your Next.js app receives them and calls revalidatePath or revalidateTag to refresh affected pages without rebuilding.
// src/app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get('secret');
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
try {
const { story } = await request.json();
revalidatePath('/');
if (story?.full_slug) {
revalidatePath(`/${story.full_slug}`);
// Walk parent paths for nested content
const slugParts = story.full_slug.split('/');
if (slugParts.length > 1) {
slugParts.pop();
while (slugParts.length > 0) {
revalidatePath(`/${slugParts.join('/')}`);
slugParts.pop();
}
}
}
return NextResponse.json({ revalidated: true, timestamp: Date.now() });
} catch (error) {
return NextResponse.json({ error: 'Revalidation failed' }, { status: 500 });
}
}
In the dashboard: Settings → Webhooks → Story published → URL https://yoursite.com/api/revalidate?secret=.... Every publish now triggers targeted revalidation. Combined with a 24-hour fallback, near-real-time freshness without per-request CMS calls.
Walk parent paths up the tree. A nested story like posts/categories/frontend/oauth-flow should also revalidate posts/categories/frontend, posts/categories, posts/ — anything that lists or aggregates the changed story.
Step 11 — Image handling
Storyblok serves assets from a.storyblok.com (and a2.storyblok.com for larger files). To use Next.js's <Image> with them, allow the hostnames:
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'a.storyblok.com', pathname: '/**' },
{ protocol: 'https', hostname: 'a2.storyblok.com', pathname: '/**' },
],
},
};
Storyblok embeds asset dimensions in the URL path: /f/{space}/{width}x{height}/{hash}/{filename}. Parse them out, skip runtime size detection:
export function getDimensionsFromStoryblokUrl(url: string) {
const dimensionsPart = url.split('/')[5];
if (!dimensionsPart?.includes('x')) {
return { width: 1920, height: 1080 };
}
const [w, h] = dimensionsPart.split('x').map((n) => Number.parseInt(n, 10));
return Number.isNaN(w) || Number.isNaN(h)
? { width: 1920, height: 1080 }
: { width: w, height: h };
}
Storyblok's image service also accepts transforms — ?w=800&h=600&fit=in&format=webp — that work like a built-in image CDN. Skip Next.js image optimization entirely for Storyblok assets if your budget cares about Vercel image transforms.
Step 12 — Generate TypeScript types from your schema
The Storyblok CLI exports Blok schemas as JSON and generates types from them:
npm install -D storyblok @storyblok/management-api-client
A scripts/generate-types.ts calls the CLI and writes src/types/storyblok.ts. Run npm run generate-types. Add a CI step that fails when generated types differ from what's committed — that's how TypeScript stays in sync with the dashboard.
The pattern that scales: commit Blok JSON schemas to the repo, generate types from them, treat schemas as the source of truth in PR review. Not full schema-as-code (Sanity wins there), but most of the way.
The pitfalls
The setup above works. The pitfalls below kill it in production.
The cv invalidation trap
Storyblok's cv is space-wide, not story-specific. Change a footer link in one folder and the global cv increments — invalidating the entire CDN cache for every story in the space.
Low-velocity teams never notice. Teams publishing 50+ updates a day watch the cache hit rate crater. Worse, the CDN doesn't return stale data when you pass an old cv — it 302-redirects to the latest, so any layer caching by URL silently bypasses itself. The fix is story-level cache versioning — an external index (Redis, Postgres) mapping story slugs to last-known cv, invalidating only affected stories. Storyblok documents the pattern; you build it yourself.
The 5MB JSON ceiling
A single Story's API response caps at 5MB. Sounds enormous. Isn't. Resolve nested relations — a Post that pulls its Author, the Author's bio, the bio's featured Stories, those Stories' related Posts — and you blow past 5MB faster than you'd guess.
The fetch silently fails. Error message is unhelpful. Fix: split deep relations into separate fetches or denormalize.
The listing rate-limit cliff
Single-Story fetches scale to 1,000+ req/sec. Listing requests at 75–100 stories drop to 6 req/sec uncached. Parallel SSG builds hit the cliff and fail.
Fix: rate-limit your build's listing calls and cache aggressively. generateStaticParams over the link map (slugs only) mostly avoids the cliff. Paginating over full story bodies hits it. When a 429 does come back, retry with exponential backoff (start at 1s, double up to 30s, give up after five attempts) — the SDK doesn't retry for you.
Schema migration is manual
Add a default value to a Blok field and existing Stories don't pick it up. The field stays undefined. Rename a field and old Stories keep the old key.
Fix: migration scripts via the Management API — iterate, patch, republish. The CLI ships helpers; the migration is yours to write. Treat schema changes as database migrations, with a script committed and run on deploy.
The component sprawl ceiling
Past ~500 component types, the Visual Editor itself becomes sluggish. The dashboard re-renders a heavy component tree on every save event; iframe latency stacks.
No platform-side fix yet. Architectural answer: consolidate. Fewer, more polymorphic Bloks with config fields beat separate types per variant. Storyblok rewards content modelers who think like UI designers, not database admins.
The pricing jump
Pricing tiers are jump-based. Exceed a single threshold — user seats, locales, asset count — and you jump from $99 to $349 with no middle ground. Localization costs an extra $20/month per locale on some plans, escalating fast on multilingual sites.
Budget the jump. The Forrester ROI is real; the pricing surprises are also real.

When Storyblok is the wrong call
A complete guide includes when to walk away.
Your content is structured data, not pages. A product catalog with 50,000 SKUs flowing into mobile apps, voice assistants, smartwatches, and a website? Pick Sanity. Storyblok's layout-aware structure traps you.
Your team has no marketing component. If only developers touch the CMS, the Visual Editor's value is zero. Pick Sanity or Payload — schema-as-code from day one.
You need full data sovereignty. Legal or compliance requires self-hosting? Pick Strapi or Directus.
Your team already lives in WordPress. Twenty contributors trained in WP? Migration cost outweighs the editing gains. Be honest about change management.
You're under $100/month in CMS budget. The free tier is generous (one user, 100 records), but the moment you grow, the Growth plan kicks in. If $99/mo doesn't pencil, Strapi self-hosted is the answer.
For the cases where Storyblok is the right call — visual-first sites, marketing-led teams, page-heavy product surfaces — the setup above gets you there. The pitfalls are real but knowable. The SDK is mature enough to trust. The marketer who can ship a landing page at 4 PM on a Friday without paging you is worth a lot.
Set the CSP header. Register every Blok. Spread
storyblokEditableon every component. Walk parent paths in revalidation. Keep the editor cookie out of production. Build migration scripts before you need them. The rest is React.