Enable Draft Mode in Next.js for Storyblok Visual Editor
Storyblok is the first headless CMS that works for developers & marketers alike.
With the introduction of React Server Components (RSC) and the Next.js app router, setting up Next.js draft mode for Storyblok can feel tricky. Some developers attempt to define their own caching layer or manage draft/published content manually, but we can achieve a cleaner solution by leveraging draft mode helpers together with the Storyblok SDK.
This tutorial will walk you through a complete setup.
This tutorial has been tested with the following package versions:
next@15.4.0
react@19.1.1
react-dom@19.1.1
@storyblok/react@5.4.3
node@22.18.0
Generate access tokens
In order to support draft content and also prevent draft content from leaking to production environments, this project will require two Content Delivery API tokens:
- Preview token: used in draft mode to fetch unpublished content.
- Public token: used in production to guarantee only published content is visible.
This separation is crucial: it means you can confidently use draft mode in your preview environment while guaranteeing that production only ever serves published content.
For more information on generating these tokens, see the documentation.
Initialize the SDK
Next, let’s create /lib/storyblok.ts
for server-side initialization. On the server, we register all components; on the client, we only enable the Visual Editor. Notice that the getStoryblokApi
function takes a preview
parameter. This parameter is what is used to determine which access to use.
// lib/storyblok.ts
import { storyblokInit, apiPlugin } from "@storyblok/react/rsc";
export const storyblokComponents = {
page: Page,
hero: Hero,
article: Article,
};
export const getStoryblokApi = (preview = false) => {
const accessToken = preview
? process.env.NEXT_PUBLIC_STORYBLOCK_PREVIEW_ACCESS_TOKEN
: process.env.STORYBLOCK_PUBLIC_ACCESS_TOKEN;
return storyblokInit({
accessToken,
use: [apiPlugin],
components: storyblokComponents,
enableFallbackComponent: true,
})();
};
On the client, no component registration is necessary. We just connect to the Visual Editor:
// lib/storyblok-client.ts
"use client";
import { apiPlugin, storyblokInit } from "@storyblok/react/rsc";
export const initStoryblokClient = () => {
storyblokInit({
accessToken: process.env.NEXT_PUBLIC_STORYBLOCK_PREVIEW_ACCESS_TOKEN,
use: [apiPlugin],
enableFallbackComponent: true,
});
};
Create a dedicated /preview
route
Then, we separate published routes from preview routes in order to initialize the Storyblok Bridge in the /preview
route and separate any preview-specific logic we need from our main route.
.
└── app/
└── [lang]/
├── [[...slug]]/
│ └── page.tsx
└── preview/
└── [[...slug]]/
├── page.tsx
└── layout.tsx
Above is a sample of what the structure of your project should look like.
When fetching data within the /preview
route, always specify the version
as draft
. This way, draft content is always available within this route.
// app/preview/[[...slug]]/page.tsx
import { getStoryblokApi } from "@/lib/storyblok";
import { StoryblokStory } from "@storyblok/react/rsc";
export default async function PreviewPage({
params,
}: {
params: { lang: string; slug?: string[] };
}) {
const { slug = ["home"], lang = "en-US" } = params;
const client = getStoryblokApi(true);
const response = await client.getStory(slug.join("/"), {
version: "draft",
resolve_relations: ["popularArticles.articles"],
language: lang,
});
return <StoryblokStory story={response.data.story} />;
}
Define a /preview
layout
Next, we need to define a layout.tsx
file for the /preview
route that enables draft mode.
export default async function PreviewLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const draft = await draftMode();
draft.enable();
return <>{children}</>
}
Then, we define a StoryblokProvider
and initialize the client-side Storyblok client when loaded within the Visual Editor.
// components/StoryblokProvider.tsx
"use client";
import { initStoryblokClient } from "@/lib/storyblok-client";
const isVisualEditor =
window.self !== window.top &&
window.location.search.includes("_storyblok");
export const StoryblokProvider = ({ children }: React.PropsWithChildren) => {
if (isVisualEditor) getClientStoryblokApi();
return children;
};
Finally, wrap your /preview
layout with the StoryblokProvider
.
// preview/layout.tsx
export default async function PreviewLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const draft = await draftMode();
draft.enable();
return (
<StoryblokProvider>
{children}
</StoryblokProvider>
);
}
Add a draft toolbar
There is a potential problem that we have to solve for: Once draft mode is enabled, it stays enabled until you disable it. That means if an editor clicks out of the Visual Editor and lands on your site, they might still see draft content.
To make this obvious, we’ll add a draft toolbar. This component:
- Only shows if draft mode is enabled.
- Gives editors a clear Exit Preview button. For help with the implementation, refer to the Next.js docs or leverage the logic within the
disablePreview
Server Action in the next step.
First, we define the DraftToolBar
component
// components/PreviewToolbar.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export default function DraftToolbar({ enabled }: { enabled?: boolean }) {
const pathname = usePathname();
if (!enabled) return null;
return (
<div>
<h1>Draft Mode</h1>
<Link prefetch={false}
href={`/api/exit-draft?pathname=${pathname}`}
>
Exit Preview
</Link>
</div>
);
}
Then, include the Toolbar in your /preview
Layout
export default async function PreviewLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const draft = await draftMode();
draft.enable();
return (
<StoryblokProvider>
<DraftToolbar enabled={true} />
{children}
</StoryblokProvider>
)
Since this component is only rendered in the /preview
route, it only appears when draft mode is active. That way, editors always know when they’re looking at unpublished content, and they can exit cleanly.
Control draft mode in the Visual Editor
Sometimes, you only want draft mode active inside the Visual Editor iframe. Otherwise, you might want to auto-disable it.
Inside the Visual Editor, Storyblok appends URL params like _storyblok
(for more information, see the documentation). We can leverage this to check whether or not we are within the Visual Editor:
useEffect(() => {
const isVisualEditor =
window.self !== window.top &&
window.location.search.includes("_storyblok");
if (!isVisualEditor) {
disablePreviewAction();
}
}, []);
Next, since the above check is done on the client, we need to define a server action to disable draft mode, which removes the related HTTP-Only cookies from the response header.
// app/actions/disablePreview.ts
"use server";
import { revalidatePath } from "next/cache";
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
export async function disablePreviewAction() {
const draft = await draftMode();
draft.disable();
revalidatePath("/");
redirect("/");
}
Handle client components with server logic
Finally, in order to have a seamless, working experience within the Visual Editor, we need to handle a common gotcha: if a server component is imported within a client component, it gets treated as a client component, breaking access to server APIs.
We can solve this by separating client logic and server logic within dedicated components by respecting client/server boundaries and importing server components and client components at the same level.
We define a client wrapper that contains all of the logic to handle interactivity:
"use client";
import Slider from "./slider"; //This is a client component that handles all of the stateful logic required to make the carousel interactive
import { storyblokEditable } from "@storyblok/react/rsc";
export default function CarouselClient({ blok, children }: any) {
return (
<div {...storyblokEditable(blok)}>
<h2>{blok.title}</h2>
<Slider>{children}</Slider>
</div>
);
}
Then we define the server child, which handles all other logic that does not require interactivity, and wrap it with the predefined client wrapper:
import { StoryblokServerComponent } from "@storyblok/react/rsc";
import CarouselClient from "./CarouselClient";
function CarouselItems({ blok }: { blok: any }) {
return (
<>
{blok.items?.map((item: any) => (
<StoryblokServerComponent blok={item} key={item._uid} />
))}
</>
);
}
export default function Carousel({ blok }: any) {
return (
<CarouselClient blok={blok}>
<CarouselItems blok={blok} />
</CarouselClient>
);
}
This ensures server logic runs where it belongs, while still enabling client interactivity.
Conclusion
In this tutorial, we walked through setting up draft mode for Storyblok’s Visual Editor using the Next.js app router. With this approach, your published site always uses the public Content Delivery API token, while drafts remain safely behind /preview
and Next.js draft mode. Editors get a seamless Visual Editor experience without the risk of unpublished content leaking out, and by keeping server and client logic cleanly separated, React’s component boundaries are respected, helping you avoid the common pitfalls developers often run into when setting up draft mode. The result is a smooth, secure, and modern preview workflow that makes the most of Next.js app router and Storyblok.