Next.js

Next.js Preview Mode for Draft Content From a Headless CMS

Letting editors see unpublished content on the real site with Next.js draft mode: a secured preview route, draft fetching, and keeping previews uncached.

5 min read
Next.jsHeadless CMSWorkflow
812 words5 min read

Why editors need it

A headless CMS separates the editor from the rendered page. That is good for performance and bad for confidence: the editor writes a page and cannot see what it will look like until it is published. So they publish to check, then unpublish, then publish again, and the live site flickers with half-finished content. Preview mode fixes this: an editor clicks "Preview" in the CMS and sees the draft on the real site, with the real design, at a private URL, without publishing.

Here is the implementation on the App Router, and the parts that are easy to get wrong.

The pieces

  1. Draft mode, Next.js's built-in flag that marks a visitor's session as "show me drafts". It is a cookie set by a route handler.
  2. A preview route that the CMS links to, which checks a secret, enables draft mode, and redirects to the page.
  3. Data fetching that asks the CMS for drafts when draft mode is on and for published content otherwise.
  4. Cache bypass so a draft is never cached and served to the public.
  5. An exit so editors can turn it off.

The preview route

TypeScript
// app/api/preview/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 path = searchParams.get('path') ?? '/'

  if (secret !== process.env.PREVIEW_SECRET) {
    return new Response('Invalid token', { status: 401 })
  }
  // Only allow internal paths; never redirect to an external URL.
  if (!path.startsWith('/') || path.startsWith('//')) {
    return new Response('Invalid path', { status: 400 })
  }

  const draft = await draftMode()
  draft.enable()
  redirect(path)
}

The CMS is configured with a preview URL template such as https://example.com/api/preview?secret=…&path=/services/{slug}. The secret lives in the environment on both sides. The path check prevents the route from being used as an open redirect.

Fetching drafts

Wherever content is fetched, check draft mode and ask the CMS accordingly. Most headless CMSs expose drafts through a different endpoint, a preview token, or a perspective/status parameter.

TypeScript
// lib/cms.ts
import { draftMode } from 'next/headers'

export async function getPage(slug: string) {
  const { isEnabled } = await draftMode()
  return cms.fetch('page', {
    slug,
    status: isEnabled ? 'draft' : 'published',
    token: isEnabled ? process.env.CMS_PREVIEW_TOKEN : undefined,
  })
}

Because draftMode() reads the request, any component calling this becomes dynamic. That is correct: previews are per session and must not be prerendered.

Keeping drafts out of the cache

This is where preview mode breaks on cached sites. If the page is cached with use cache or otherwise prerendered, a draft fetched during preview must not be written into the shared cache.

With Cache Components, reading draftMode() inside a cached scope is not allowed, which forces the right structure: the draft check happens outside the cache, and the cached path is only taken when draft mode is off.

TSX
export default async function Page({ params }) {
  const { slug } = await params
  const { isEnabled } = await draftMode()
  if (isEnabled) return <DraftPage slug={slug} />   // dynamic, uncached
  return <CachedPage slug={slug} />                  // 'use cache' inside
}

The public gets the cached page. The editor, with the draft cookie, gets a live render. Nothing the editor sees is ever stored for anyone else.

Keeping drafts out of the index

Preview responses should carry noindex and should not appear in the sitemap. Set the robots metadata dynamically when draft mode is on. Search engines never carry the draft cookie, so this is belt and braces, but a preview URL shared in a chat can be crawled by link-preview bots.

The exit

A small route that calls draft.disable() and redirects home, linked from a visible bar that appears only when draft mode is on: "You are previewing draft content. Exit preview." Without the bar, editors forget they are in preview and report bugs that are not there.

Live editing, optionally

Several CMSs offer a live preview that updates the page as the editor types, via a client-side listener or an iframe overlay. It is a nice addition on top of the pattern above, and it depends on the same draft fetching and cache bypass. Get the basic preview right first.

What the editor experiences

Click Preview in the CMS. The real site opens on the draft. Edit, save, reload, see the change. Click Exit preview when done. Publish when happy, and the CMS webhook revalidates the cached page so the public sees it within seconds.

That workflow is the point of headless done well: the editor gets confidence, the public gets a cached page, and nothing half-finished ever goes live. It is part of every headless Next.js build we deliver, because a CMS the editor does not trust is a CMS they will route around.

All writingHire me for this