NextjsDraft Mode & Preview

Draft Mode & Preview

Content editors working in a headless CMS need to see what an unpublished draft will look like before it goes live. Draft Mode is the App Router's built-in mechanism for that: a special cookie that tells your app to fetch draft content instead of published content, for that one browser session only.

How it works
  • The CMS links to a preview Route Handler on your site, passing a secret token and the path to preview.

  • The Route Handler validates the secret, calls draftMode().enable(), and redirects to the requested path.

  • While Draft Mode is enabled, your data-fetching code checks draftMode().isEnabled and requests draft content instead of published content.

  • Any route visited while Draft Mode is on is rendered dynamically, bypassing the Full Route Cache, so editors always see the latest draft.

  • The editor calls a matching disable route (or the cookie expires) to exit preview and go back to seeing the published site.

Setting up the preview Route Handler

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

  // Always validate the secret before enabling Draft Mode
  if (secret !== process.env.DRAFT_MODE_SECRET || !slug) {
    return new Response('Invalid token', { status: 401 })
  }

  const draft = await draftMode()
  draft.enable()

  redirect(`/blog/${slug}`)
}
Warning
Never skip validating the secret token. Draft Mode has no built-in access control of its own — anyone who can guess or leak your preview URL can enable it unless the secret check genuinely rejects invalid or missing tokens. Treat DRAFT_MODE_SECRET like any other credential: environment variable, never committed, rotated if leaked.
Reading Draft Mode in your data fetching

TSX
// app/blog/[slug]/page.tsx
import { draftMode } from 'next/headers'

async function getPost(slug: string, isDraft: boolean) {
  const endpoint = isDraft
    ? `https://cms.example.com/drafts/${slug}`
    : `https://cms.example.com/published/${slug}`

  const res = await fetch(endpoint, { cache: isDraft ? 'no-store' : 'default' })
  return res.json()
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const { isEnabled } = await draftMode()
  const post = await getPost(params.slug, isEnabled)

  return (
    <article>
      {isEnabled && <div>Preview Mode — showing unpublished content</div>}
      <h1>{post.title}</h1>
    </article>
  )
}
Exiting Draft Mode

TSX
// 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('/')
}
What changes while Draft Mode is on

Aspect

Published (normal)

Draft Mode enabled

Rendering

Static / cached where possible

Forced dynamic rendering

Full Route Cache

Used normally

Bypassed

Scope

Everyone

Only the current browser session (cookie-based)

Data source

Published content

Draft/unpublished content, as your code decides

Note
Draft Mode only controls whether the cookie is set and that the route renders dynamically — it does not automatically know how to fetch draft content from your CMS. You are responsible for branching your own fetch logic on draftMode().isEnabled, as shown above.
Typical CMS preview flow
  1. Editor clicks "Preview" in the CMS while editing an unpublished post.

  2. CMS opens https://yoursite.com/api/draft?secret=...&slug=my-post.

  3. Your Route Handler validates the secret, enables Draft Mode, and redirects to /blog/my-post.

  4. The blog post page detects Draft Mode is on and fetches the unpublished version from the CMS.

  5. Editor reviews the draft rendered on the real site, with real styling and layout.

  6. Editor clicks "Exit preview" (or navigates to your disable route), returning to the normal published view.

Tip
Because Draft Mode forces dynamic rendering, keep the preview flow scoped to just the content that needs previewing — don't leave Draft Mode enabled longer than necessary, since it opts every visited route out of the Full Route Cache for that session.