From content strategy to code, JoyConf brings the Storyblok community together - Register Now!

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.

hint:

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.

Author

Daniel Mendoza

Daniel Mendoza

Daniel is a Senior Developer Relations Engineer at Storyblok who blends technical know-how with a genuine passion for developer experience. He loves sharing knowledge, creating helpful content, and being an active part of the developer community.