WritingHeadless CMS Preview with Next.js Draft Mode: A Complete Setup Guide — Clixo
6 min readheadless-cms, nextjs, draft-mode, content-preview

Headless CMS Preview with Next.js Draft Mode: A Complete Setup Guide

Learn how to wire up live content preview for a headless CMS using Next.js Draft Mode — covering route handlers, preview tokens, CMS configuration, and common pitfalls.

Editors working in a headless CMS face a frustrating gap: they update content in the admin panel and have no idea what it will look like on the actual site until it is published. The feedback loop is broken. Some teams work around this with screenshots and staging environments, but neither scales. The correct solution is live preview — unpublished content rendered in the real frontend, in real time.

Next.js Draft Mode is the native mechanism for this. Setting it up correctly requires wiring together the CMS, a Next.js route handler, and your data-fetching layer in a way that most tutorials only partially explain.

How Next.js Draft Mode Works

Draft Mode is a session-based cookie mechanism built into Next.js. When a request arrives with the Draft Mode cookie set, Next.js bypasses its cache and treats the request as a preview request. Your data-fetching code can detect this and switch from fetching published content to fetching draft content from the CMS.

The flow:

  1. The CMS sends the editor to a Next.js preview route, passing a secret token and the content's slug
  2. The Next.js route handler validates the token, enables Draft Mode via the draftMode() API, and redirects to the content URL
  3. The content page detects that Draft Mode is active and fetches draft content from the CMS instead of published content
  4. The editor sees the draft content rendered in the real frontend

The Draft Mode cookie persists until explicitly cleared, so editors can navigate the site and see draft content throughout their session.

Setting Up the Preview Route Handler

Create an API route at app/api/preview/route.ts:

import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { NextRequest } from 'next/server'
 
const PREVIEW_SECRET = process.env.PREVIEW_SECRET
 
export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl
  const secret = searchParams.get('secret')
  const slug = searchParams.get('slug')
 
  if (secret !== PREVIEW_SECRET || !slug) {
    return new Response('Invalid token', { status: 401 })
  }
 
  const draft = await draftMode()
  draft.enable()
 
  redirect(slug)
}

Store PREVIEW_SECRET as an environment variable — a long random string that only your CMS and your server know. This prevents anyone who knows a slug from triggering preview mode without the secret.

Fetching Draft Content in Page Components

In your page component, check whether Draft Mode is active and switch the CMS query accordingly:

import { draftMode } from 'next/headers'
 
export default async function BlogPost({ params }: { params: { slug: string } }) {
  const { isEnabled } = await draftMode()
 
  const post = isEnabled
    ? await fetchDraftPost(params.slug)   // fetches unpublished drafts
    : await fetchPublishedPost(params.slug) // fetches only published content
 
  return <PostLayout post={post} />
}

The fetchDraftPost function uses your CMS's draft API — in Sanity, this means querying with perspective: 'previewDrafts'. In Contentful, it means hitting the Preview API endpoint with a Preview API token instead of the Delivery API token.

Configuring the CMS Side

Sanity

Sanity's draft preview uses the perspective parameter in GROQ queries:

import { createClient } from '@sanity/client'
 
const previewClient = createClient({
  // your config
  token: process.env.SANITY_PREVIEW_TOKEN,
  useCdn: false,
})
 
async function fetchDraftPost(slug: string) {
  return previewClient.fetch(
    `*[_type == "post" && slug.current == $slug][0]`,
    { slug },
    { perspective: 'previewDrafts' }
  )
}

The SANITY_PREVIEW_TOKEN must be a token with read access to draft documents. Keep this server-side only — it should never be exposed to the browser.

Contentful

Contentful uses a separate Preview API with its own host and token:

import { createClient } from 'contentful'
 
const previewClient = createClient({
  space: process.env.CONTENTFUL_SPACE_ID!,
  accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN!,
  host: 'preview.contentful.com',
})

The preview.contentful.com host returns unpublished entries. The delivery host returns only published content.

Configuring the Preview URL in Your CMS

Both Sanity and Contentful let you define a preview URL that the CMS opens when an editor clicks "Preview." Set it to your preview route handler with the required parameters:

For Sanity Studio, configure the preview URL in the document type's preview definition or through the @sanity/presentation plugin:

https://yoursite.com/api/preview?secret=YOUR_SECRET&slug=/blog/{slug}

For Contentful, set the preview URL in the Content Preview configuration under Space Settings. Use Contentful's field token syntax to inject the slug dynamically.

Adding a Draft Mode Exit Route

Editors need a way to exit Draft Mode when they are done previewing. Create an exit route:

// app/api/exit-preview/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
 
export async function GET() {
  const draft = await draftMode()
  draft.disable()
  redirect('/')
}

Add a visible banner when Draft Mode is active so editors know they are in preview state. Render it in your root layout:

const { isEnabled } = await draftMode()
 
{isEnabled && (
  <div className="fixed top-0 inset-x-0 bg-yellow-400 text-black text-center py-2 z-50">
    Preview Mode active —
    <a href="/api/exit-preview" className="underline ml-1">Exit Preview</a>
  </div>
)}

Common Mistakes

Using the Delivery API token for preview. The Delivery API only returns published content. Draft preview requires a separate token with access to draft documents.

Not validating the preview secret. An unprotected preview route allows anyone to enable Draft Mode on any URL. Always validate the secret.

Forgetting useCdn: false on the preview client. CDN-cached responses may not include the latest draft changes. Preview clients must bypass the CDN.

Not handling the case where draft content does not exist. When an editor previews a brand-new document that has never been published, the slug may not resolve to a published page. Handle this gracefully — redirect to a generic preview URL or return a clear "draft content" placeholder.

Exposing preview tokens to the browser. Preview tokens have elevated access. Keep them in server-only environment variables.


Live preview is one of the highest-leverage investments in editor experience for a headless CMS setup. Editors who can see their changes before publishing make fewer mistakes and work faster.

If you are setting up a headless CMS with Next.js and want the preview workflow built correctly from the start, Clixo builds content systems with production-grade editorial tooling.