Next.js Draft Mode: Headless CMS Preview URLs and Live Editing in App Router (2026)

Enable Next.js Draft Mode in the App Router with draftMode(), signed __prerender_bypass cookies, and secure preview URLs for Sanity, Contentful, and DatoCMS.

Next.js Draft Mode Guide (2026)

Updated: July 12, 2026

Next.js Draft Mode is an App Router feature that lets editors bypass the static cache and render pages on demand with unpublished CMS content, by setting a signed __prerender_bypass cookie via draftMode().enable() inside a Route Handler. It replaces the older setPreviewData Preview Mode from the Pages Router, works with any headless CMS that supports configurable preview URLs (Sanity, Contentful, DatoCMS, Payload, Strapi), and integrates with Server Components through the async draftMode() function exported from next/headers. I've shipped this flow on four production sites in the last year, and the moving parts (cookies, iframes, CSP, and cache boundaries) are exactly where most teams get stuck. This guide covers the whole path from route handler to secure production preview.

  • Draft Mode is enabled by calling await draftMode().enable() inside a Route Handler, which sets an HttpOnly __prerender_bypass cookie that bypasses the fetch cache and ISR.
  • The route handler must validate a shared secret and a slug before enabling, then redirect to the resolved slug. Never redirect to raw searchParams, since that creates an open redirect.
  • In Next.js 15 and 16, draftMode() is async and returns a Promise. You must await it in Server Components and Route Handlers or you'll hit a runtime error.
  • Draft Mode does not run on the Edge Runtime. Keep the enable/disable route handlers on Node.js, since the Edge cookies API doesn't expose the signed bypass cookie machinery.
  • Add X-Robots-Tag: noindex in middleware.ts when the draft cookie is present so search engines never index preview URLs, and pass prefetch={false} on the disable link so next/link doesn't accidentally clear the cookie.

What is Draft Mode in Next.js?

Draft Mode is Next.js's official mechanism for serving unpublished CMS content to authenticated editors while everyone else continues to see the static, cached, production build. It exists because static rendering is what you want 99.9% of the time (fast, cheap, cacheable), but that same static output is exactly what gets in the way when an editor hits "Preview" on a draft entry in Sanity or Contentful. The static page shows the last published version, not the draft they just wrote.

Under the hood, Draft Mode flips the whole rendering pipeline into dynamic mode for that one visitor. When draftMode().enable() runs inside a Route Handler, Next.js writes a signed cookie called __prerender_bypass (HttpOnly, Secure, SameSite=None). Any subsequent request that carries that cookie skips the fetch cache, skips the ISR response cache, and re-executes every Server Component with fresh data on every request. Other visitors, without the cookie, still get the cached HTML.

This model has three big advantages over the older Pages Router setPreviewData API: it's compatible with the App Router and Server Components, it doesn't shove serializable JSON into the cookie, and it composes cleanly with the new use cache directive in Next.js 16. It's now the officially recommended flow. The Pages Router Preview Mode docs now redirect readers to Draft Mode for new work.

How do I enable Draft Mode in Next.js?

You enable Draft Mode by creating a Route Handler that calls await draftMode().enable() and then redirects to the target path. The convention is to put this at app/api/draft/route.ts, though the filename doesn't actually matter. The CMS just needs to know where to point its preview button.

Here's the minimal working handler for Next.js 15 and 16. Heads up: draftMode() is now async, and forgetting the await is the single most common error when upgrading from Next.js 14.

// 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')

  // 1. Validate the shared secret against an env var.
  if (secret !== process.env.DRAFT_MODE_SECRET || !slug) {
    return new Response('Invalid token', { status: 401 })
  }

  // 2. Fetch the post from the CMS to confirm the slug exists.
  //    This prevents attackers redirecting to arbitrary paths.
  const post = await fetchPostBySlug(slug)
  if (!post) {
    return new Response('Invalid slug', { status: 401 })
  }

  // 3. Enable Draft Mode (sets the __prerender_bypass cookie).
  const draft = await draftMode()
  draft.enable()

  // 4. Redirect to the resolved post path, not the raw searchParam.
  redirect(`/posts/${post.slug}`)
}

Two design choices in that handler are worth calling out. First, we fetch the post from the CMS before enabling Draft Mode so we can guarantee the slug is real. If you skip that check and just redirect to searchParams.get('slug'), your route becomes an open-redirect gadget. An attacker can send /api/draft?secret=leaked&slug=https://evil.example.com and phish your editors. Second, we use redirect() from next/navigation, which sets the cookie on the redirect response so the browser carries it into the next request.

Securing the preview URL with a shared secret

A Draft Mode route handler with no auth is a footgun. Anyone who hits /api/draft would enable Draft Mode for themselves and see unpublished content forever. So, the standard mitigation is a shared secret (a long random string known only to your Next.js deployment and your CMS) passed as a query parameter and validated on every request.

Generate the secret once with openssl rand -base64 32, add it as DRAFT_MODE_SECRET in your Next.js environment (Vercel, Railway, or your own host), and paste the same value into the CMS preview URL template. In Sanity that field lives under Studio's Presentation config. In Contentful it's the Preview URL in each content model. In DatoCMS it's the Environment settings' Draft URL field.

For an extra defense-in-depth layer, keep the secret out of your code and in a rotating store. On Vercel, the draftMode API reference notes you can stash the secret in Edge Config and read it at request time. Rotating the secret then becomes a one-second config update instead of a redeploy. This composes well with the patterns from our Next.js middleware and proxy guide if you're already reading from Edge Config in middleware.ts.

Using draftMode() in Server Components

Once Draft Mode is enabled, your Server Components need to actually change their behavior. Otherwise they'll keep returning the published content, because the cookie's presence alone doesn't tell your data layer to fetch drafts. The pattern is to read isEnabled and branch on it:

// app/posts/[slug]/page.tsx
import { draftMode } from 'next/headers'
import { notFound } from 'next/navigation'
import { sanityClient } from '@/lib/sanity'

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const { isEnabled } = await draftMode()

  const post = await sanityClient.fetch(
    `*[_type == "post" && slug.current == $slug][0]`,
    { slug },
    {
      // Switch perspective when Draft Mode is on.
      perspective: isEnabled ? 'previewDrafts' : 'published',
      // Disable Next.js fetch cache in preview.
      next: isEnabled ? { revalidate: 0 } : { revalidate: 3600 },
    }
  )

  if (!post) notFound()

  return (
    <article>
      {isEnabled && <PreviewBanner />}
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.body }} />
    </article>
  )
}

Two things to notice here. The perspective parameter tells the Sanity Content Lake which version of each document to serve, and you must actually flip it, or Draft Mode enables the cookie but you keep seeing published content. And the next: { revalidate: 0 } option opts this specific fetch out of the fetch cache when previewing, since use cache and unstable_cache both respect the Draft Mode cookie only if your fetch calls are wired up correctly.

The <PreviewBanner /> is a UX detail worth mentioning. Without it, editors have no visual signal they're looking at a draft, and they'll just assume the page has been published. A red bar at the top of the page with an "Exit preview" button prevents days of confusion. (Honestly, I've watched a team ship a botched hero section for two days because nobody realized preview was still on.)

Sanity, Contentful, and DatoCMS integrations

Each headless CMS ships its own preview flow, but they all funnel into the same Route Handler pattern. Here's what changes between the big three in 2026:

CMS Preview URL format Draft API Live editing?
Sanity Configured in Presentation tool; /api/draft?sanity-preview-secret=...&slug=... GROQ perspective: 'previewDrafts' Yes (Presentation + Visual Editing)
Contentful Content model Preview URL; /api/draft?secret=...&slug=... Preview API host preview.contentful.com Via Live Preview SDK
DatoCMS Draft URL in project settings; supports {entry.slug} tokens includeDrafts: true on GraphQL client Real-time via useQuerySubscription

Sanity Presentation

Sanity's Presentation tool embeds your Next.js site inside the Studio in an iframe and highlights DOM nodes tied to Sanity fields, giving editors click-to-edit visual editing. The catch: the iframe needs your app's Content Security Policy to allow frame-ancestors 'self' https://your-studio.sanity.studio, or the frame just blanks silently. I've covered the CSP config in detail in our Next.js Content Security Policy guide. The exact directive is frame-ancestors, and it must be set as a response header, not a meta tag.

Install @sanity/preview-url-secret to move the secret out of your CMS config and generate short-lived preview URLs on demand. This solves the "editor left their preview URL in a Slack channel" leak, since the secret changes every session.

Contentful Preview API

Contentful's twist is that draft content lives on a separate API host (preview.contentful.com) with a different access token. Your data layer needs to switch both the host and the token when Draft Mode is on. The John Kavanagh writeup on Contentful preview also flags a subtle bug: if you cache the Contentful client instance at module scope, the first request wins and the same instance is reused for drafts and published content. Instantiate the client per-request when Draft Mode is enabled.

Cache behavior and the __prerender_bypass cookie

The __prerender_bypass cookie does more than just flag a request as "draft". It changes the entire response caching contract. When the cookie is present, Next.js serves the page with:

Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate

That header tells every layer between your app and the editor's browser (CDN, Vercel Edge Network, Cloudflare, browser cache) to never cache the response. It's essential. If the CDN accidentally cached a draft response with a public Cache-Control, one editor's preview would leak to every other visitor for the duration of the cache TTL.

The cookie also short-circuits the fetch cache. Any fetch() call inside a Server Component, when Draft Mode is on, ignores the revalidate and tags options and hits the origin every time. This is why draft pages feel slower than production: you're paying the full round-trip cost of every CMS API call. Editors accept this trade-off because they're the only ones seeing the slower page.

If you're already using cache tagging patterns from our Next.js 16 Cache Components guide, the mental model to keep is this: Draft Mode is orthogonal to cacheTag and cacheLife. It sits above them and disables the cache entirely when active, so you don't need to add "draft" tags or think about invalidation for previews.

What is the difference between Preview Mode and Draft Mode?

Preview Mode was the Pages Router feature (Next.js 9.3 through the end of Pages Router support). It used setPreviewData() to serialize an arbitrary JSON payload into a signed cookie, which getStaticProps and getServerSideProps could read via context.preview and context.previewData. Draft Mode is the App Router replacement. It drops the payload (the cookie is just an opaque bypass flag), moves the API into next/headers, and integrates with the async rendering model.

The migration path is mechanical for most sites. Replace setPreviewData(data) with draftMode().enable(), replace context.preview checks with (await draftMode()).isEnabled, and move any data that was in the preview payload (like a Contentful space ID override) into environment variables or a separate lookup. The one thing you lose is the ability to pass ad-hoc state through the cookie, but in practice, that was almost always used for the CMS token, which is better read from an env var anyway.

Common pitfalls: Edge Runtime, prefetch, and CSP iframes

Draft Mode fails in production for the same handful of reasons, over and over. I've hit every one of these on a project at some point. Here's the checklist I now run before shipping any preview flow.

Don't run the enable route on the Edge Runtime

If you add export const runtime = 'edge' to app/api/draft/route.ts, draftMode().enable() throws because the Edge Runtime doesn't expose the signed cookie primitives that Draft Mode uses under the hood. Keep the preview route on Node.js. This matches the guidance in our Edge vs Node runtime guide. Use Edge for latency-sensitive geo work, Node for anything that touches Node-specific APIs.

Pass prefetch={false} on the disable link

Next.js prefetches <Link> targets by default. If your "Exit preview" button is a next/link pointing at /api/disable-draft, the browser will call the disable handler as soon as the editor's cursor hovers over the link, clearing the cookie before they've even clicked. Set prefetch={false} on that link, or use a plain <form action="/api/disable-draft" method="get"> since forms are never prefetched. (I hit this exact bug shipping a client project last spring and spent a good hour blaming a middleware config.)

Set X-Robots-Tag: noindex when Draft Mode is on

If an editor accidentally shares a draft URL that still has the cookie set (via a screen recording, a shared browser, an SSO session), Google might crawl and index the draft page. Add a middleware that stamps X-Robots-Tag: noindex, nofollow whenever the __prerender_bypass cookie is present:

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const response = NextResponse.next()
  const hasDraftCookie = request.cookies.has('__prerender_bypass')

  if (hasDraftCookie) {
    response.headers.set('X-Robots-Tag', 'noindex, nofollow')
  }

  return response
}

Loosen frame-ancestors CSP for visual editing

Tools like Sanity Presentation, Contentful Live Preview, and Payload Live Preview embed your site inside the Studio's iframe. A strict Content-Security-Policy: frame-ancestors 'none' will blank the iframe with no error message in the editor. Explicitly allowlist the Studio origin: frame-ancestors 'self' https://studio.example.com.

Exiting Draft Mode cleanly

Add a disable handler alongside the enable one. It's a one-liner that calls draftMode().disable() and redirects home:

// app/api/disable-draft/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'

export async function GET() {
  const draft = await draftMode()
  draft.disable()
  redirect('/')
}

Wire this to a persistent "Exit preview" button in your preview banner. Editors will forget the cookie is still set otherwise, and the next time they hit the site they'll wonder why they can't share a link with someone (the shared visitor sees the published page, the editor still sees the draft). The banner also gives them a psychological anchor for "I'm in preview mode right now."

For deeper metadata SEO integration, like canonicalization, OG images that reflect draft content, and structured data, the patterns in our Next.js SEO metadata API guide compose naturally with Draft Mode. Just remember to gate any draft-only metadata on isEnabled so the published page doesn't leak preview values.

Frequently Asked Questions

How do I check if Draft Mode is enabled in Next.js?

Call await draftMode() inside a Server Component or Route Handler and read the isEnabled property. In Next.js 15 and 16 the function is async and must be awaited. Never read the raw __prerender_bypass cookie yourself; it's HttpOnly and signed, so the API is the only safe access path.

Can I use Draft Mode on the Edge Runtime?

No. The Edge Runtime doesn't support the cookie signing primitives that draftMode() uses internally, so calling enable() or disable() throws at runtime. Keep your preview route handlers on the Node.js runtime by omitting export const runtime = 'edge'.

Why is my Sanity draft mode not working after enabling?

Almost always because the GROQ client is still using perspective: 'published'. Enabling Draft Mode sets the cookie, but your data layer must actually branch on isEnabled and switch the perspective to 'previewDrafts'. Double-check that your Sanity client is instantiated per-request, not memoized at module scope with the wrong perspective baked in.

Is Draft Mode secure enough for production?

Yes, provided you validate a strong secret on every enable request, use HTTPS in production so the Secure cookie flag actually protects the cookie in transit, and resolve the redirect target from a CMS lookup rather than searchParams. For extra defense-in-depth, layer session-based auth on top so only logged-in editors can enable Draft Mode.

Does Draft Mode work with ISR and revalidateTag?

Yes, and it takes precedence over both. When the draft cookie is present, Next.js bypasses the ISR response cache entirely, so pages re-render on every request regardless of revalidate settings. revalidateTag and revalidatePath still work normally for public traffic; Draft Mode just makes the editor's own view uncached.

Editorial Team
About the Author Editorial Team

Our team of expert writers and editors.