
Next.js Draft Mode
If you've ever worked with a headless CMS, you already know the workflow: a content editor writes a draft, saves it, and then wants to see exactly how it will look on the live site before publishing. The problem is that everything about a well-built Next.js application is designed to fight this request. Pages are cached, statically generated, and revalidated on a schedule — precisely so that regular visitors get fast, cheap responses instead of hitting your CMS on every request. An editor previewing unpublished content needs the opposite: guaranteed-fresh data, right now, without waiting for the next revalidation window.
Draft Mode is Next.js's answer to that tension. It's a narrowly-scoped escape hatch that lets a single request skip every caching layer in your app — the fetch cache, use cache boundaries, unstable_cache, and the ISR response cache — while every other visitor keeps getting the fast, cached version. It's not a general-purpose "disable caching" switch, and it's not meant to be left on. It's a cookie-gated bypass that exists for exactly one job: letting someone with a valid preview link see what's about to go live.
This article walks through building a complete Draft Mode integration end to end — the Route Handler that turns it on, the security model that keeps random visitors from triggering it, the preview banner that tells editors they're in draft mode, and the two different ways your data-fetching code might need to branch depending on how your CMS actually serves draft content. Along the way I'll flag the parts of this that are easy to get subtly wrong, because most of them won't show up until you're debugging why a preview link silently doesn't work.
What Draft Mode Actually Bypasses
It's worth being precise about this, because "bypasses caching" undersells how many different caches are actually involved. When Draft Mode is active for a request:
fetch()calls skip the Next.js fetch cache entirely and go straight to the network. This is the big one — if your data fetching usesfetch, enabling Draft Mode is often the only change you need to make anywhere in your data layer.- Components and functions wrapped in
'use cache're-execute on every request, and critically, their results are not written back to the cache. This matters — it means a draft request doesn't accidentally poison the cache with unpublished content that a regular visitor might then see. unstable_cachereads and writes are bypassed the same way.- The page itself is excluded from the ISR response cache and gets served with
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate— the same header combination you'd use for a genuinely uncacheable, per-user response.
That last point deserves a second look, because it tells you something the docs don't spell out directly: Draft Mode isn't just skipping your data caches, it's telling every layer between your server and the browser — CDNs included — not to cache this specific response at all. That's exactly what you want for a preview: you'd be in trouble if a CDN cached the draft version and started serving it to real visitors.
The other thing worth internalizing early: Draft Mode is a request-scoped bypass, not a user-scoped one. It works by setting a cookie on the editor's browser. Anyone whose browser carries that cookie sees the bypassed, fresh version; everyone else — including the same editor, in a different browser, or an incognito tab — sees the normal cached version. There's no concept of "draft mode for user X only" beyond whatever your CMS's own auth already does before it hands out a preview link.
The Contract This Guide Assumes
Before writing any code, it helps to be explicit about the shape of the integration, because "connect Next.js to my CMS's preview feature" can mean slightly different things depending on your CMS. The common pattern — and the one most headless CMS preview integrations are built around — looks like this:
- Your CMS supports configurable preview URLs (Contentful, Sanity, Storyblok, and most others do).
- When an editor clicks "Preview" in the CMS, it opens a URL like
/api/draft?secret=XXX&slug=/posts/fooin a new tab. Thesecretis a shared token only your app and your CMS know;slugis the path the editor wants to preview. - Your Next.js app validates that secret, turns on Draft Mode, and redirects the browser to the actual page at
slug.
If your CMS matches that shape — and most do — everything below applies directly. If it doesn't (say, your CMS can't be configured with a custom preview URL), you can still use the same underlying mechanism, you'll just need to construct and distribute that draft URL yourself.
Step 1: A Route Handler That Turns Draft Mode On
The entry point is a Route Handler — it can live anywhere, but app/api/draft/route.ts is the conventional spot:
// app/api/draft/route.ts
import { draftMode } from "next/headers";
export async function GET(request: Request) {
const draft = await draftMode();
draft.enable();
return new Response("Draft mode is enabled");
}
draft.enable() sets a cookie named __prerender_bypass. From that point on, any request from that browser carrying the cookie skips every cache layer described above. You can verify this yourself by hitting /api/draft directly and checking the Set-Cookie response header in your browser's dev tools — it's a genuinely useful sanity check before wiring up the rest of the flow.
As written, though, this handler is wide open. Anyone who knows the URL — which is just your-domain.com/api/draft, not exactly a secret — can enable Draft Mode for themselves. That's fine for a local experiment, but it's not something you'd want live, so the next step closes that gap.
Why GET and not POST? This is one of those details that looks like an oversight until you think about who's calling it. Semantically, GET requests are supposed to be safe and side-effect-free, and setting a cookie is very much a side effect — normally that argues for POST. But the caller here is the CMS's own preview button, which opens a URL in a new browser tab. That's fundamentally a GET. You don't get to choose the HTTP method your CMS's preview link uses, so the entry handler has to be a GET, even though it isn't purely idempotent. The exit flow later on doesn't have that constraint, and does use POST — more on why that matters when we get to prefetching.
Step 2: Locking the Handler Down With a Shared Secret
To stop random visitors from flipping Draft Mode on, you validate a token that only your app and your CMS know, plus the slug the editor wants to preview:
// app/api/draft/route.ts
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get("secret");
const slug = searchParams.get("slug");
// This secret should only be known to this Route Handler and the CMS
if (secret !== process.env.DRAFT_MODE_SECRET || !slug) {
return new Response("Invalid token", { status: 401 });
}
// Verify the slug exists in the CMS before enabling Draft Mode
const post = await getPostBySlug(slug);
if (!post) {
return new Response("Invalid slug", { status: 401 });
}
const draft = await draftMode();
draft.enable();
// Redirect using the slug from the fetched post, not the raw query param
redirect(post.slug);
}
Two things in this handler are easy to skip and both are real security issues, not just style preferences.
The secret needs to actually be a secret. Hardcoding it inline (as the raw docs example does, for brevity) means it ends up committed to your repo. Pull it from an environment variable instead, and treat it the same way you'd treat any other credential — rotate it if it ever leaks, and don't reuse it as a secret anywhere else in your app.
Redirect using data you fetched, not the raw slug query parameter. This one is subtle enough that it's worth spelling out why. If you did redirect(slug) directly, you'd be redirecting the browser to a path built from unvalidated user input. That's a textbook open redirect vulnerability — an attacker could craft a link like /api/draft?secret=leaked-secret&slug=https://evil.example.com and, if your redirect logic isn't careful, bounce your own domain's trusted-looking link straight to a phishing site. By looking up the post from your CMS first and redirecting to post.slug — a value you control, not the caller — you sidestep the whole problem. This is a good general instinct for redirects: whenever a redirect target comes from a query parameter, ask whether you can resolve it against something you own instead of trusting it verbatim.
Step 3: Fetching Draft Content Without Special-Casing It
Here's the part of Draft Mode that genuinely simplifies your life: if your CMS serves draft and published content from the same URL (which is common — many CMSs return the latest draft revision to any authenticated preview request, and the published revision otherwise), your page components don't need to know Draft Mode exists at all:
// app/posts/[slug]/page.tsx
async function getPost(slug: string) {
const res = await fetch(`https://cms.example.com/posts/${slug}`);
return res.json();
}
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return (
<main>
<h1>{post.title}</h1>
<article>{post.content}</article>
</main>
);
}
When the __prerender_bypass cookie is present, that fetch() call automatically skips the cache and hits your CMS directly, picking up whatever the CMS's own auth/session layer decides to return for that request. When the cookie isn't present, the exact same code path can be served from cache as normal. You get two behaviors out of one code path, which is exactly the point.
This only holds if your CMS's draft and published content genuinely live at the same endpoint, though — if your CMS instead exposes a separate preview API (a different base URL, different auth, sometimes a completely different response shape), you'll need to branch explicitly. That pattern is covered further down.
Step 4: Showing Editors They're in Preview Mode
An editor previewing draft content has no visual cue that they're looking at something unpublished unless you build one. The standard pattern is a banner rendered from a shared layout, so it shows up on every page while Draft Mode is active:
// app/preview-banner.tsx
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
async function exitPreview() {
"use server";
const draft = await draftMode();
draft.disable();
redirect("/");
}
export async function PreviewBanner() {
const { isEnabled } = await draftMode();
if (!isEnabled) return null;
return (
<aside role="status">
Preview mode is on.{" "}
<form action={exitPreview}>
<button type="submit">Exit preview</button>
</form>
</aside>
);
}
Drop <PreviewBanner /> into your root layout and it renders nothing at all for regular visitors, and a dismissible banner for anyone in Draft Mode.
Here's a gotcha that will genuinely confuse you if you don't know about it in advance: the exit flow uses a <form> with a Server Action, not a <Link>. This isn't a stylistic choice — it's load-bearing. Next.js prefetches <Link> components by default, which means if you built the "exit preview" control as a link to a GET route that disables Draft Mode, the prefetch itself would fire that request and silently clear the editor's cookie before they ever clicked anything. Forms aren't prefetched regardless of HTTP method, which is exactly why the exit flow needs to be a form-based Server Action (or a POST Route Handler) rather than a link. If you ever find yourself building a "click this link to change some server state" pattern anywhere in a Next.js app and it seems to trigger itself, prefetching is usually the first thing to suspect.
Draft Mode With Cache Components
If your project has the cacheComponents flag enabled, you can read Draft Mode's isEnabled flag from inside a 'use cache' boundary to conditionally render a preview indicator at the component level, not just at the page/layout level:
// app/posts/[slug]/page.tsx
import { draftMode } from "next/headers";
async function Post({ slug }: { slug: string }) {
"use cache";
const post = await fetch(`https://cms.example.com/posts/${slug}`).then((r) =>
r.json(),
);
const { isEnabled } = await draftMode();
return (
<article>
{isEnabled && <p role="status">Draft preview</p>}
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
Because Draft Mode's bypass is layered underneath 'use cache', the component still re-executes with fresh data on every draft request — the caching directive doesn't get in the way of the bypass, it just means you can now read isEnabled from deeper in your component tree instead of only at the layout level.
One restriction worth knowing before you hit it: you cannot call draftMode().enable() or .disable() from inside a 'use cache' scope. Toggling Draft Mode is a side effect, and caching directives are explicitly for pure, cacheable output — mixing the two doesn't make sense conceptually and Next.js won't let you do it. Keep the actual enable/disable calls in your Route Handler or Server Action, and only read isEnabled from within cached components.
When Your CMS Uses a Separate Draft Endpoint
Not every CMS serves draft and published content from the same place. If yours requires a different base URL or different credentials for preview content, branch your fetch logic on isEnabled explicitly:
// app/posts/[slug]/page.tsx
import { draftMode } from "next/headers";
async function getPost(slug: string) {
const { isEnabled } = await draftMode();
const baseUrl = isEnabled
? "https://cms.example.com/preview"
: "https://cms.example.com/published";
const res = await fetch(`${baseUrl}/posts/${slug}`);
return res.json();
}
The cache bypass described earlier still applies to both branches — Draft Mode doesn't care which URL you end up fetching from, it just guarantees that whichever one you choose, the response isn't cached or reused. The branching here is purely about pointing at the right upstream source, not about the caching behavior itself.
Testing Draft Mode Locally
A few practical notes that will save you time when you're setting this up for the first time:
Visit the Route Handler directly first. Before wiring up the CMS side at all, hit http://localhost:3000/api/draft?secret=your-secret&slug=/some-real-slug in a browser and confirm you get redirected and see the preview banner. If that works, the CMS integration is just a matter of pointing its preview button at the right URL — you've already proven the Next.js side works.
Check the cookie, not just the visual result. If something's not working, open your browser's dev tools, go to the Application/Storage tab, and confirm __prerender_bypass is actually present. It's easy to assume a redirect "worked" because you didn't see an error, when actually the cookie never got set because of a secret mismatch that returned a silent 401 you didn't notice.
Test in an incognito window when you want to see the "normal" experience again. Since Draft Mode lives entirely in a cookie on your browser, closing the tab doesn't turn it off — only calling disable() does. If you want to double-check what a regular visitor sees, open a private/incognito window rather than assuming your regular browser tab has reverted.
Common Mistakes
Treating Draft Mode as a general staging environment. It's tempting to reach for Draft Mode any time you want to preview something unusual — a redesign, a feature flag, an A/B test variant. Resist this. Draft Mode's entire cache-bypass machinery is built around the specific "one editor previewing one unpublished piece of content" use case. For anything broader, you want actual environment separation (a staging deployment) or a proper feature-flagging setup, not a caching bypass wearing a staging hat.
Forgetting that the secret needs real secrecy. It's common to see the secret hardcoded during initial development and then simply forgotten about before shipping. Since anyone who has the secret and a valid slug can enable Draft Mode, treat it exactly like an API key: environment variable, not committed to version control, rotated if you suspect it's leaked (for example, if it ever appeared in client-side code, browser history, or a public URL that got indexed).
Assuming Draft Mode disables caching for everyone. It doesn't — it's scoped to the request carrying the cookie. If you're debugging why a change "isn't showing up" for a colleague you asked to check something, and they're not the one with the cookie set, they're seeing the normal cached experience, not a bug in your Draft Mode setup.
Building the exit flow as a link instead of a form. As covered above, this one bites you specifically because of Next.js's default link prefetching — the exit action fires before the user consciously triggers it, because the browser prefetched it as soon as the link scrolled into view.
How This Compares to the Pages Router's Preview Mode
If you've worked with older Next.js projects, you may recognize this whole pattern from the Pages Router, where the equivalent feature was called Preview Mode, built around res.setPreviewData() and context.preview inside getStaticProps. Draft Mode is the App Router's version of the exact same idea, rebuilt around Server Components, cookies read through next/headers, and the newer use cache model rather than getStaticProps. If you're migrating a project from the Pages Router, this is a fairly mechanical swap — the CMS-side configuration (secret token, preview URL, redirect-to-slug flow) barely changes; it's the Next.js-side implementation that gets simpler and more explicit.
Key Takeaways
| Concern | What to do |
|---|---|
| Enabling Draft Mode | Call draftMode().enable() inside a Route Handler, gated by a shared secret |
| Disabling Draft Mode | Call draftMode().disable(), triggered via a form/Server Action — never a <Link> |
| Redirect target | Redirect to a slug you resolved from the CMS, never the raw query param |
| Fetching draft data (same endpoint) | No changes needed — fetch() automatically bypasses the cache |
| Fetching draft data (separate endpoint) | Branch the base URL on draftMode().isEnabled |
Reading state inside 'use cache' | Reading isEnabled is fine; enabling/disabling is not allowed there |
| Showing editors it's active | Render a banner conditionally from isEnabled, ideally in the root layout |
| Security | Secret in an environment variable, validated slug, Cache-Control: private, no-cache applied automatically |
Draft Mode is a small feature with a lot of moving parts hiding underneath a simple cookie. Once it's wired up, though, it fades into the background exactly the way it should: editors get a "Preview" button that just works, regular visitors never notice it exists, and your caching strategy for the rest of the site doesn't have to compromise to accommodate it.


