docs/01-app/02-guides/draft-mode.mdx
Draft Mode lets editors see how draft or in-progress content will render on your site, without waiting for revalidation. While an editor is in Draft Mode, cached or pre-rendered content is bypassed, and fetched from upstream sources directly. Other visitors continue to see the cached or pre-rendered version of the page.
Your data-fetching code does not need to change if your CMS serves draft and published content from the same URL. Otherwise, see When your CMS uses a separate draft endpoint.
When Draft Mode is enabled for a request:
fetch() calls skip the Next.js fetch cache and hit the network directly.'use cache' re-execute on every request, and their results are not saved to the cache.unstable_cache reads and writes are bypassed in the same way.Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate.The effect applies whether the page is statically generated, served from cache, or revalidated through ISR.
This guide assumes:
/api/draft?secret=XXX&slug=/posts/foo in a new tab when an editor clicks "Preview". The secret is a shared token; the slug is the path to preview.With that contract in mind, the rest of this guide walks through:
Then, depending on your setup:
'use cache' boundary.isEnabled.Good to know:
GETis meant to be a safe, read-only method. Operations that affect future requests, like enabling Draft Mode via a cookie, should usePOST. The entry handler usesGETbecause we're assuming a CMS preview integration: the CMS opens the URL in a new browser tab, which is aGETrequest. The exit flow in Step 4 usesPOST(via Server Action orPOSTRoute Handler).
Create a Route Handler that sets the Draft Mode cookie. It can have any name, for example, 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')
}
import { draftMode } from 'next/headers'
export async function GET(request) {
const draft = await draftMode()
draft.enable()
return new Response('Draft mode is enabled')
}
draft.enable() sets a cookie named __prerender_bypass. Subsequent requests that carry this cookie skip every cache layer listed above.
You can test this manually by visiting /api/draft and looking at your browser's developer tools. Notice the Set-Cookie response header.
As written, the handler is public: anyone who hits /api/draft enables Draft Mode for themselves. Step 2 closes that with a shared secret so only your CMS can call it.
These steps assume that the headless CMS you're using supports setting custom draft URLs. If it doesn't, you can still use this method to secure your draft URLs, but you'll need to construct and access the draft URL manually. The specific steps will vary depending on which headless CMS you're using.
To securely access the Route Handler from your headless CMS:
app/api/draft/route.ts). For example:https://<your-site>/api/draft?secret=<token>&slug=<path>
<your-site>should be your deployment domain.<token>should be replaced with the secret token you generated.<path>should be the path for the page that you want to view. If you want to view/posts/one, then you should use&slug=/posts/one.Your headless CMS might allow you to include a variable in the draft URL so that
<path>can be set dynamically based on the CMS's data like so:&slug=/posts/{entry.fields.slug}
slug parameter exists (if not, the request should fail), call draft.enable() to set the cookie, then redirect the browser to the path specified by slug: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 !== 'MY_SECRET_TOKEN' || !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 to the path from the fetched post, not from searchParams,
// to avoid open redirect vulnerabilities
redirect(post.slug)
}
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET(request) {
const { searchParams } = new URL(request.url)
const secret = searchParams.get('secret')
const slug = searchParams.get('slug')
if (secret !== 'MY_SECRET_TOKEN' || !slug) {
return new Response('Invalid token', { status: 401 })
}
const post = await getPostBySlug(slug)
if (!post) {
return new Response('Invalid slug', { status: 401 })
}
const draft = await draftMode()
draft.enable()
redirect(post.slug)
}
If it succeeds, the browser is redirected to the target path with the Draft Mode cookie set.
Because Draft Mode bypasses the cache automatically, your page does not need to know whether Draft Mode is on to receive fresh content. Fetch as you normally would:
async function getPost(slug: string) {
const res = await fetch(`https://cms.example.com/posts/${slug}`)
return res.json()
}
export default async function Page({ params }: PageProps<'/posts/[slug]'>) {
const { slug } = await params
const post = await getPost(slug)
return (
<main>
<h1>{post.title}</h1>
<article>{post.content}</article>
</main>
)
}
async function getPost(slug) {
const res = await fetch(`https://cms.example.com/posts/${slug}`)
return res.json()
}
export default async function Page({ params }) {
const { slug } = await params
const post = await getPost(slug)
return (
<main>
<h1>{post.title}</h1>
<article>{post.content}</article>
</main>
)
}
When the Draft Mode cookie is present, the fetch above skips the Next.js fetch cache and hits your CMS for the current draft. When it is not, the same request can be served from cache as usual.
If your CMS uses a different URL for drafts rather than serving them from the same endpoint, see When your CMS uses a separate draft endpoint.
isEnabled is most useful as a signal to the editor: a banner that confirms they are looking at draft content, plus a way to exit. Render an indicator from your root layout so it appears on every preview page.
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>
)
}
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>
)
}
Exiting Draft Mode also works with a GET Route Handler, but a POST is semantically more correct, for example via a form submitted through a Server Action or to a POST Route Handler.
If you do use a GET Route Handler, trigger it from a <form method="GET"> rather than a <Link>. Next.js prefetches <Link> components by default, which would clear the cookie before the editor clicks. Forms are not prefetched, regardless of method.
You can read isEnabled inside a 'use cache' scope to render a preview indicator from a cached component. The cache bypass still applies, so the component re-executes with fresh data on every draft request.
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>
)
}
import { draftMode } from 'next/headers'
async function Post({ slug }) {
'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>
)
}
Good to know:
draftMode().enable()anddraftMode().disable()cannot be called inside a caching directive scope; toggle Draft Mode from a Route Handler or Server Action instead.
If your CMS exposes draft content at a different URL or requires different credentials, branch your fetch on isEnabled:
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()
}
import { draftMode } from 'next/headers'
async function getPost(slug) {
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 still applies to both branches; the fork only chooses where to read from.