Draft mode and CMS previews in Next.js
Build a safe CMS Preview link: check the secret and the post, fetch drafts with a preview token, and keep drafts out of every cache visitors read.
- 01Build the Preview linkCheck a signed, expiring link for one post, ask the CMS for the post, and redirect only to the CMS's own path.
- 02Show drafts to editors onlyFetch drafts with the CMS's preview token when draftMode() is on, and know what draft mode does to pages and "use cache".
- 03Manage the cookieKnow where the bypass cookie works and how long, and turn draft mode off with a Server Action, not a prefetched link.
An editor previews a post on the real site
The tea shop's editors write news in a CMS, a content management system: a website where they write, save drafts and publish. The news pages are prerendered and cached, so a visitor gets them fast. But an editor who changed a post wants to see the change on the real site before publishing it, with the real layout and the real styles. Request-time APIs showed the switch for that, draftMode(): a Route Handler turns it on with a cookie, and a page reads isEnabled. Here is the whole flow a CMS needs, in a real Next.js 16.4.3 project. Open the Steps tab: real Chrome visits the post, then opens the CMS's Preview link, then clicks Exit preview, then opens the post again.
app/news/[slug]/page.tsximport { notFound } from "next/navigation";import { getPost, getPublishedSlugs } from "@/lib/cms"; export async function generateStaticParams() { const slugs = await getPublishedSlugs(); return slugs.map((slug) => ({ slug }));} export default async function NewsPost({ params }: PageProps<"/news/[slug]">) { const { slug } = await params; const post = await getPost(slug); if (!post) notFound(); return ( <article> <h1>{post.title}</h1> <p>{post.body}</p> </article> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
You are previewing drafts.
Our autumn teas arrive on Monday.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (2), without the files it loaded
GET/api/draft?slug=autumn-teas&expires=4102444800&signature=fc71c6a4b0d35d11d29205b0cbd2a26a59d2fe5ef450e4fc031f115c125f01bc307a whole pageGET/news/autumn-teas200a whole page
Step 1 shows the published post: Our autumn teas arrive soon. In step 2, Chrome opens the address the CMS builds when an editor clicks Preview: /api/draft with the post's slug, an expiry time and the CMS's signature of both. That request answered 307, a redirect, and the browser landed on /news/autumn-teas, which now says Our autumn teas arrive on Monday. under a banner, You are previewing drafts. Step 3 clicks Exit preview, and step 4 shows the published post again.
Line 11 of app/news/[slug]/page.tsx calls getPost, which asks the CMS for the draft only when draft mode is on. The Terminal tab shows ○ /news/autumn-teas: for everyone else, the page is the one the build prerendered.
/api/draft checks the signed link and asks the CMS for the post, then answers 307 with the __prerender_bypass cookie, and the browser opens the post with the draft in it.A theater is changing its show. At the stage door, a guard checks a note signed by the director, dated for tonight, and hands over a pass. With the pass, a crew member watches the new scenes on the real stage, with the real lights. The audience at the front door still sees last week's show, and nobody records the rehearsal for them.
- In real life: The stage door, where a guard checks a signed, dated note for tonight's rehearsal
- In Next.js:
/api/draft, which checks the signed link and the post - In real life: The crew pass the guard hands over
- In Next.js: The
__prerender_bypasscookie - In real life: The new scenes, played only for people with a pass
- In Next.js: The draft, rendered only for requests with the cookie
- In real life: The audience, who still see last week's show
- In Next.js: Visitors, who get the prerendered page
Draft mode renders a page for one browser at request time, with draft content, while everyone else keeps the cached page. A Route Handler checks the CMS's signed link and the post, turns draft mode on with a cookie, and redirects to the post. Your data code fetches drafts only when draftMode() says it's on, and Next.js keeps every draft response out of its caches.
The Preview link's Route Handler
Most CMSs let you set a preview URL: the address they open in a new tab when an editor clicks Preview, with the post's slug filled in. Whoever has that address gets the bypass cookie, and with it every draft on the site, so treat the address like a password. The shop's CMS doesn't put a fixed secret in it. It signs each link: it computes an HMAC, a code made from the slug, an expiry time a few minutes away, and a key only the CMS and the shop know. Changing the slug or the time without the key gives a code that doesn't match. The key is 32 random bytes, generated with a tool like openssl rand -hex 32 and kept in DRAFT_SECRET, a server environment variable. A Route Handler is a public address, so it checks everything before it turns draft mode on:
app/api/draft/route.tsimport { createHmac, timingSafeEqual } from "node:crypto";import { draftMode } from "next/headers";import { redirect } from "next/navigation";import { getDraftPost } from "@/lib/cms"; function isSignedLink(slug: string, expires: string, signature: string) { const key = Buffer.from(process.env.DRAFT_SECRET ?? "", "hex"); if (key.length !== 32 || !/^[0-9a-f]{64}$/.test(signature)) return false; const expected = createHmac("sha256", key).update(`${slug}.${expires}`).digest(); return timingSafeEqual(Buffer.from(signature, "hex"), expected);} export async function GET(request: Request) { const { searchParams } = new URL(request.url); const slug = searchParams.get("slug") ?? ""; const expires = searchParams.get("expires") ?? ""; if (!/^[a-z0-9-]+$/.test(slug) || !/^\d{10}$/.test(expires)) { return new Response("Invalid link.", { status: 400 }); } if (!isSignedLink(slug, expires, searchParams.get("signature") ?? "")) { return new Response("Invalid signature.", { status: 401 }); } if (Number(expires) * 1000 < Date.now()) { return new Response("Link expired.", { status: 401 }); } const post = await getDraftPost(slug); if (!post) { return new Response("No such post.", { status: 404 }); } (await draftMode()).enable(); redirect(`/news/${post.slug}`);}GET /api/draft?slug=autumn-teas&expires=4102444800&signature=fc71c6a4b0d35d11d29205b0cbd2a26a59d2fe5ef450e4fc031f115c125f01bc307
- Response headers
varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchlocation/news/autumn-teasset-cookie__prerender_bypass=021dbd8c40a50f5d0e705e68bf17af7e; Path=/; Secure; HttpOnly; SameSite=none
No HTML arrived: a redirect sends none
The handler makes four checks, and answers as soon as one fails:
- The link's shape. Line 17 accepts a slug of lowercase letters, digits and dashes, and an expiry of ten digits; line 18 answers
400for anything else, like a whole address as the slug. - The signature. Lines 6 to 11 check it. Line 7 decodes the key from hex, and line 8 refuses a key that isn't 32 bytes, so a missing or short key never matches anything. That check only catches a missing or cut-off key: a key is strong because it was generated from random bytes. Line 9 computes the HMAC of
slug.expireswith the key, and line 10 compares it with the link's signature as two 32-byte buffers withtimingSafeEqual, which takes the same time however many bytes match. (!==on strings stops at the first difference, so its timing can leak how much of a guess was right.) Line 21 answers401for a made-up signature, and for a signature copied from another post's link. - The time. Line 23 compares the expiry with the clock, and line 24 answers
401 Link expired. - The post. Line 26 asks the CMS for the draft, and line 28 answers
404if it has none.
Only then does line 30 call enable(), and line 31 redirects to /news/ plus the slug the CMS sent back. The Network tab shows every answer: 401 Invalid signature. for a made-up signature, 400 Invalid link. for another site as the slug, 401 Link expired., 401 Invalid signature. for the autumn post's signature on slug=winter-teas, 404 No such post., and for the real link 307 with location: /news/autumn-teas and a set-cookie header. A redirect from a Route Handler is a 307, as Redirects explains. The recording's good links expire in the year 2100, so the recording works whenever it's made; a real CMS signs links that expire in minutes.
Turning draft mode on with a GET is an exception for this one job: a CMS opens the link in a new tab, and a new tab can only send a GET. A switch in your own app, like Exit preview below, uses a POST.
What a leaked link still gives away
A signed link only opens the post it names, and only until it expires. Once someone has used it, though, the cookie it set works on every page, for every draft, until the browser closes. So also:
- Don't share preview links; each editor clicks Preview in the CMS.
- Keep query strings out of your request logs, or drop the
signaturebefore you log an address. - Change
DRAFT_SECRETin the CMS and the shop at once if a link or the key might have leaked. - For more, ask for a signed-in editor too:
/api/draftandgetPostcan check the editor's session as well as the cookie, as the data access layer checks access.
If a CMS can only add a fixed secret to its preview URL, the link never expires and works for any slug: everything above matters even more. Compare that secret as two SHA-256 hashes with timingSafeEqual, as Request-time APIs does.
Why the path comes from the CMS
Preview links get pasted into chats, saved in browser history and written to server logs. Here is the handler as it's often first written, with a fixed secret in the address, and the same attack sent to both versions:
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
if (searchParams.get("secret") !== process.env.DRAFT_SECRET) {
return new Response("Invalid secret.", { status: 401 });
}
(await draftMode()).enable();
redirect(searchParams.get("slug") ?? "/");
}
app/api/draft/route.tsimport { draftMode } from "next/headers";import { redirect } from "next/navigation"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); if (searchParams.get("secret") !== process.env.DRAFT_SECRET) { return new Response("Invalid secret.", { status: 401 }); } (await draftMode()).enable(); redirect(searchParams.get("slug") ?? "/");}GET /api/draft?secret=3f9a1c7e5b2d8f4a6c0e9b1d7a3f5c8e2b6d4f0a9c1e7b3d5f8a2c6e0b4d9f17&slug=https://evil.example/login307
- Response headers
varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchlocationhttps://evil.example/loginset-cookie__prerender_bypass=9272e01c1ef6ec9da96529de25692c42; Path=/; Secure; HttpOnly; SameSite=none
No HTML arrived: a redirect sends none
Line 10 redirects to whatever slug says. With the right secret and slug=https://evil.example/login, it answered 307 to that site, with the bypass cookie set.
Line 10 of the first version redirects wherever slug says. With the secret and slug=https://evil.example/login, it answered 307 with location: https://evil.example/login: an open redirect. Anyone with a leaked link can send people from an address on your site to a page that copies your sign-in form. The safe version answered 400 and set no cookie. Its redirect only ever uses post.slug, from the CMS's answer.
Draft content, only for draft requests
A CMS usually keeps drafts behind a preview token: a key that lets the shop read unpublished posts. The shop keeps it in CMS_PREVIEW_TOKEN, on the server. lib/cms.ts uses it only when draft mode is on:
import "server-only";
import { cacheTag } from "next/cache";
import { draftMode } from "next/headers";
export type Post = { slug: string; title: string; body: string };
const cms = process.env.CMS_URL;
export async function getPublishedSlugs(): Promise<string[]> {
"use cache";
cacheTag("posts");
const response = await fetch(`${cms}/posts`);
const posts: Post[] = await response.json();
return posts.map((post) => post.slug);
}
async function getPublishedPost(slug: string): Promise<Post | undefined> {
"use cache";
cacheTag("posts");
const response = await fetch(`${cms}/posts/${slug}`);
return response.ok ? response.json() : undefined;
}
export async function getDraftPost(slug: string): Promise<Post | undefined> {
const response = await fetch(`${cms}/drafts/${slug}`, {
headers: { authorization: `Bearer ${process.env.CMS_PREVIEW_TOKEN}` },
});
return response.ok ? response.json() : undefined;
}
export async function getPost(slug: string) {
const { isEnabled } = await draftMode();
return isEnabled ? getDraftPost(slug) : getPublishedPost(slug);
}
- Line 1,
import "server-only", stops the build if a Client Component ever imports this file, so the token can't end up in the browser's JavaScript. - Lines 17 to 22,
getPublishedPost, fetch/posts/…with no token, inside "use cache", taggedpostsfor revalidation when an editor publishes. - Lines 24 to 29,
getDraftPost, fetch/drafts/…with the token in anauthorizationheader. They aren't cached, on purpose. - Lines 31 to 34,
getPost, readisEnabledand pick one. The page only callsgetPost.
The CMS in the recording is a stand-in that kept every request it got. Like a real CMS, it answers /drafts only with the right token in the authorization header, and 401 without it; every draft request in the recording got 200. During the build, it was asked for /posts and /posts/autumn-teas. While the server ran, it was asked only for drafts: every editor request reached it, and no visitor's did. Open the Network tab on the editor's request:
lib/cms.tsimport "server-only";import { cacheTag } from "next/cache";import { draftMode } from "next/headers"; export type Post = { slug: string; title: string; body: string }; const cms = process.env.CMS_URL; export async function getPublishedSlugs(): Promise<string[]> { "use cache"; cacheTag("posts"); const response = await fetch(`${cms}/posts`); const posts: Post[] = await response.json(); return posts.map((post) => post.slug);} async function getPublishedPost(slug: string): Promise<Post | undefined> { "use cache"; cacheTag("posts"); const response = await fetch(`${cms}/posts/${slug}`); return response.ok ? response.json() : undefined;} export async function getDraftPost(slug: string): Promise<Post | undefined> { const response = await fetch(`${cms}/drafts/${slug}`, { headers: { authorization: `Bearer ${process.env.CMS_PREVIEW_TOKEN}` }, }); return response.ok ? response.json() : undefined;} export async function getPost(slug: string) { const { isEnabled } = await draftMode(); return isEnabled ? getDraftPost(slug) : getPublishedPost(slug);}GET /news/autumn-teas200
- Sent with the request
cookie__prerender_bypass=021dbd8c40a50f5d0e705e68bf17af7e
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-EncodingCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidatex-nextjs-cacheHITx-nextjs-prerender1X-Powered-ByNext.jsContent-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 10 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<aside role="status">
<p>You are previewing drafts.</p>
<form action="" enctype="multipart/form-data" method="POST"><input type="hidden" name="$ACTION_ID_0076250cfc3820a0aaa75b8be77c11595e81847de9"><button>Exit preview</button></form>
</aside>
<!--&-->
<!--&-->
<!--&-->
<article>
<h1>Autumn teas</h1>
<p>Our autumn teas arrive on Monday.</p>
</article>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>The recorder sent this request with the cookie the 307 had set, as a browser does. The page has the draft and the banner. Its cache-control is private, no-cache, no-store, max-age=0, must-revalidate: no browser cache or CDN (the servers around the world that keep copies of your pages) may keep it. The next request on the list, a visitor, after the editor, got the published post with s-maxage=900, the cached page.
The editor's response also says x-nextjs-cache: HIT. Don't read that header in draft mode: Next.js 16.4.3 looks up a stored page only when draft mode is off (lines 717 to 725 of app-page-runtime.ts), and still labels this response HIT. The page's text, a draft that no build ever saw, shows where it came from.
getPost fetches the draft with the preview token.Draft mode and cached data
getPost above keeps drafts out of the cache by never caching them. What if a draft goes through a cached function? The docs say isEnabled can be read inside "use cache". This version has one cached getPost that reads draft mode inside the cache and picks the CMS's address:
import "server-only";import { cacheTag } from "next/cache";import { draftMode } from "next/headers"; export type Post = { slug: string; title: string; body: string }; const cms = process.env.CMS_URL; export async function getPublishedSlugs(): Promise<string[]> { "use cache"; cacheTag("posts"); const response = await fetch(`${cms}/posts`); const posts: Post[] = await response.json(); return posts.map((post) => post.slug);} export async function getPost(slug: string): Promise<Post | undefined> { "use cache"; cacheTag("posts"); const { isEnabled } = await draftMode(); const response = await fetch(`${cms}/${isEnabled ? "drafts" : "posts"}/${slug}`, { headers: isEnabled ? { authorization: `Bearer ${process.env.CMS_PREVIEW_TOKEN}` } : {}, }); return response.ok ? response.json() : undefined;} export async function getDraftPost(slug: string): Promise<Post | undefined> { const response = await fetch(`${cms}/drafts/${slug}`, { headers: { authorization: `Bearer ${process.env.CMS_PREVIEW_TOKEN}` }, }); return response.ok ? response.json() : undefined;} Line 18 caches getPost, and line 20 reads draft mode inside it. The cache key is the function and its argument, the slug. Draft mode isn't part of it. If Next.js stored the editor's result, the next visitor would get the draft under the same key. So Next.js does two things for a request in draft mode (shouldForceRevalidate and lines 3577 and 3578 of use-cache-wrapper.ts):
- It skips the stored copies, so the function runs again.
- It never saves what the function returns.
The spring post was published after the last build, so the build didn't prerender it, and its first visitor's request ran getPost. Then the editor opened the Preview link and the post twice, and another visitor came:
lib/cms.tsimport "server-only";import { cacheTag } from "next/cache";import { draftMode } from "next/headers"; export type Post = { slug: string; title: string; body: string }; const cms = process.env.CMS_URL; export async function getPublishedSlugs(): Promise<string[]> { "use cache"; cacheTag("posts"); const response = await fetch(`${cms}/posts`); const posts: Post[] = await response.json(); return posts.map((post) => post.slug);} export async function getPost(slug: string): Promise<Post | undefined> { "use cache"; cacheTag("posts"); const { isEnabled } = await draftMode(); const response = await fetch(`${cms}/${isEnabled ? "drafts" : "posts"}/${slug}`, { headers: isEnabled ? { authorization: `Bearer ${process.env.CMS_PREVIEW_TOKEN}` } : {}, }); return response.ok ? response.json() : undefined;} export async function getDraftPost(slug: string): Promise<Post | undefined> { const response = await fetch(`${cms}/drafts/${slug}`, { headers: { authorization: `Bearer ${process.env.CMS_PREVIEW_TOKEN}` }, }); return response.ok ? response.json() : undefined;}GET /news/spring-teas200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=900, stale-while-revalidate=31535100Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 17 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<!--&-->
<!--&-->
<article>
<h1>Spring teas</h1>
<p>Our spring teas are sold out.</p>
</article>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>The stand-in CMS got /posts/spring-teas once, for the first visitor. Then /drafts/spring-teas three times: once from the Route Handler, which checks the post, and once for each of the editor's two views, each showing Our spring teas are back in March. The last visitor got Our spring teas are sold out., and the CMS wasn't asked at all: the stored published copy was still there, untouched by the editor's two runs.
"use cache" function runs again and its result is never stored. The visitor after the editor still gets the stored published post, and the CMS isn't asked.Each row is one place a copy can be kept, and what draft mode does to it in Next.js 16.4.3:
| Where a copy is kept | In a draft request | For everyone else |
|---|---|---|
| A prerendered page | Skipped. The page is rendered for the request and never saved for others. | Visitors keep getting the page the build made. |
A "use cache" function | Runs again, and its result is never stored. | The stored copy from a visitor's request stays as it was. |
| The browser and any CDN | The response says private, no-cache, no-store. | Nothing between the server and the editor keeps the draft. |
Two limits. draftMode().enable() and disable() throw inside "use cache" (lines 215 to 224 of draft-mode.ts): turn draft mode on and off only in a Route Handler or a Server Action. And cookies() and headers() still can't be read inside "use cache", even in draft mode. Both versions of getPost work; the first one also makes it plain, in your own code, that drafts are never cached.
Turning draft mode off
The cookie ends when the browser closes. Editors also need a way out that doesn't mean closing the browser. The banner in the layout shows one, only in draft mode:
import { draftMode } from "next/headers";
import { exitPreview } from "./actions";
export async function PreviewBanner() {
const { isEnabled } = await draftMode();
if (!isEnabled) return null;
return (
<aside role="status">
<p>You are previewing drafts.</p>
<form action={exitPreview}>
<button>Exit preview</button>
</form>
</aside>
);
}
"use server";
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
export async function exitPreview() {
(await draftMode()).disable();
redirect("/");
}
Line 6 of the banner returns nothing for visitors. Lines 10 to 12 are a form whose action is exitPreview, a Server Action: line 6 of app/actions.ts calls disable(), which sets the cookie again with an empty value and an expiry date in 1970, so the browser deletes it (lines 78 to 93). Line 7 redirects to the home page. Anyone can call this action, and that's fine: it only ends draft mode for the browser that calls it.
Why a form and not a link? <Link> prefetches the page it points to as soon as it shows, and a GET Route Handler runs for a prefetch like for a click. Compare the two banners in real Chrome:
import Link from "next/link";
import { draftMode } from "next/headers";
export async function PreviewBanner() {
const { isEnabled } = await draftMode();
if (!isEnabled) return null;
return (
<aside role="status">
<p>You are previewing drafts.</p>
<Link href="/api/draft/exit">Exit preview</Link>
</aside>
);
}
app/preview-banner.tsximport { draftMode } from "next/headers";import { exitPreview } from "./actions"; export async function PreviewBanner() { const { isEnabled } = await draftMode(); if (!isEnabled) return null; return ( <aside role="status"> <p>You are previewing drafts.</p> <form action={exitPreview}> <button>Exit preview</button> </form> </aside> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Our autumn teas arrive soon.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
GET/news/autumn-teas200a whole page
Draft mode stayed on until Chrome clicked Exit preview. The click posted the form, the Server Action deleted the cookie and redirected to /, and the post opened as published.
With the Link, step 2 waits until the router has fetched /api/draft/exit?_rsc=… with next-router-prefetch: 1, and that fetch was answered 307: the handler ran and deleted the cookie, and step 3 shows the published post, though nobody clicked anything. With the form, draft mode lasted until the click: step 3's request is a POST with a next-action header, and then the home page showed. Forms are never prefetched. If you keep a GET handler for leaving draft mode, link to it with prefetch={false}, as the docs say, or use a plain <form method="get">.
Safe, or a problem?
Every card is a choice someone made while building a Preview link. Sort each one:
- A Preview link the CMS signs for one post, expiring in minutes, checked with
timingSafeEqual searchParams.get("secret") !== process.env.DRAFT_SECRET- Asking the CMS for the post, then redirecting to
/news/${post.slug} redirect(searchParams.get("slug"))- Fetching
/draftswith the preview token only whenisEnabledis true - Keeping the preview token in
NEXT_PUBLIC_CMS_PREVIEW_TOKEN - Exit preview as a form with a Server Action
- Exit preview as a
<Link>to a GET Route Handler
Sort each choice in a draft mode setup by whether it keeps drafts, secrets and visitors safe.
The pattern: check before you enable, redirect only to paths you made, keep the token in server code, never cache a draft for everyone, and leave draft mode only when the editor asks.
Common mix-ups
- "Draft mode makes the page dynamic for everyone." No. The post stayed
○, and visitors before and after the editor got the prerendered page. - "A secret in the Preview link is enough." No. A fixed secret works for any slug and never expires, and the recording's first version redirected to another site. Sign each link for one post with an expiry, check the slug, and redirect to the CMS's path.
- "draftMode() is for one post." No. The cookie's
Path=/turns it on for the whole site, until the editor leaves or closes the browser. - "A cached function would store the editor's draft." No. In draft mode it runs again and its result is never saved; the next visitor got the published copy.
- "x-nextjs-cache: HIT means the draft came from a cache." No. In draft mode, Next.js renders without looking in its page cache; the header isn't telling you where the page came from.
- "An Exit preview link is harmless." No. The router prefetched it and ended draft mode before anyone clicked.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
The CMS's Preview link reaches this handler with a good signature for slug=autumn-teas. Which cookie does its 307 set?
import { createHmac, timingSafeEqual } from "node:crypto";
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
import { getDraftPost } from "@/lib/cms";
function isSignedLink(slug: string, expires: string, signature: string) {
const key = Buffer.from(process.env.DRAFT_SECRET ?? "", "hex");
if (key.length !== 32 || !/^[0-9a-f]{64}$/.test(signature)) return false;
const expected = createHmac("sha256", key).update(`${slug}.${expires}`).digest();
return timingSafeEqual(Buffer.from(signature, "hex"), expected);
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const slug = searchParams.get("slug") ?? "";
const expires = searchParams.get("expires") ?? "";
if (!/^[a-z0-9-]+$/.test(slug) || !/^\d{10}$/.test(expires)) {
return new Response("Invalid link.", { status: 400 });
}
if (!isSignedLink(slug, expires, searchParams.get("signature") ?? "")) {
return new Response("Invalid signature.", { status: 401 });
}
if (Number(expires) * 1000 < Date.now()) {
return new Response("Link expired.", { status: 401 });
}
const post = await getDraftPost(slug);
if (!post) {
return new Response("No such post.", { status: 404 });
}
(await draftMode()).enable();
redirect(`/news/${post.slug}`);
}
__prerender_bypass. enable() sets it with the build's preview ID as its value, Path=/ and HttpOnly (and, in next start, Secure and SameSite=none), and the 307 carries it to the browser.
The editor has just viewed /news/autumn-teas with Our autumn teas arrive on Monday. Right after, a visitor opens the same address. What does the visitor see?
import "server-only";
import { cacheTag } from "next/cache";
import { draftMode } from "next/headers";
export type Post = { slug: string; title: string; body: string };
const cms = process.env.CMS_URL;
export async function getPublishedSlugs(): Promise<string[]> {
"use cache";
cacheTag("posts");
const response = await fetch(`${cms}/posts`);
const posts: Post[] = await response.json();
return posts.map((post) => post.slug);
}
async function getPublishedPost(slug: string): Promise<Post | undefined> {
"use cache";
cacheTag("posts");
const response = await fetch(`${cms}/posts/${slug}`);
return response.ok ? response.json() : undefined;
}
export async function getDraftPost(slug: string): Promise<Post | undefined> {
const response = await fetch(`${cms}/drafts/${slug}`, {
headers: { authorization: `Bearer ${process.env.CMS_PREVIEW_TOKEN}` },
});
return response.ok ? response.json() : undefined;
}
export async function getPost(slug: string) {
const { isEnabled } = await draftMode();
return isEnabled ? getDraftPost(slug) : getPublishedPost(slug);
}
Our autumn teas arrive soon. The editor's requests were rendered for them and never saved. The visitor's request had no cookie, so it got the prerendered page, as the recording shows.
This handler sends the browser anywhere slug says. Fix it so the redirect always stays on the site. Where should the redirect's path come from?
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
if (searchParams.get("secret") !== process.env.DRAFT_SECRET) {
return new Response("Invalid secret.", { status: 401 });
}
(await draftMode()).enable();
redirect(searchParams.get("slug") ?? "/");
}
import { createHmac, timingSafeEqual } from "node:crypto";
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
import { getDraftPost } from "@/lib/cms";
function isSignedLink(slug: string, expires: string, signature: string) {
const key = Buffer.from(process.env.DRAFT_SECRET ?? "", "hex");
if (key.length !== 32 || !/^[0-9a-f]{64}$/.test(signature)) return false;
const expected = createHmac("sha256", key).update(`${slug}.${expires}`).digest();
return timingSafeEqual(Buffer.from(signature, "hex"), expected);
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const slug = searchParams.get("slug") ?? "";
const expires = searchParams.get("expires") ?? "";
if (!/^[a-z0-9-]+$/.test(slug) || !/^\d{10}$/.test(expires)) {
return new Response("Invalid link.", { status: 400 });
}
if (!isSignedLink(slug, expires, searchParams.get("signature") ?? "")) {
return new Response("Invalid signature.", { status: 401 });
}
if (Number(expires) * 1000 < Date.now()) {
return new Response("Link expired.", { status: 401 });
}
const post = await getDraftPost(slug);
if (!post) {
return new Response("No such post.", { status: 404 });
}
(await draftMode()).enable();
redirect(`/news/${post.slug}`);
}
From the CMS. Line 17 accepts only a slug's characters, line 26 asks the CMS for the post, and line 31 redirects to /news/${post.slug}. The recording answered 400 for slug=https://evil.example/login.
Line 6 compares the secret with !==. Replace it with a comparison whose time doesn't depend on how much of a guess matches. Which function from node:crypto compares two buffers that way?
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
if (searchParams.get("secret") !== process.env.DRAFT_SECRET) {
return new Response("Invalid secret.", { status: 401 });
}
(await draftMode()).enable();
redirect(searchParams.get("slug") ?? "/");
}
import { createHmac, timingSafeEqual } from "node:crypto";
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
import { getDraftPost } from "@/lib/cms";
function isSignedLink(slug: string, expires: string, signature: string) {
const key = Buffer.from(process.env.DRAFT_SECRET ?? "", "hex");
if (key.length !== 32 || !/^[0-9a-f]{64}$/.test(signature)) return false;
const expected = createHmac("sha256", key).update(`${slug}.${expires}`).digest();
return timingSafeEqual(Buffer.from(signature, "hex"), expected);
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const slug = searchParams.get("slug") ?? "";
const expires = searchParams.get("expires") ?? "";
if (!/^[a-z0-9-]+$/.test(slug) || !/^\d{10}$/.test(expires)) {
return new Response("Invalid link.", { status: 400 });
}
if (!isSignedLink(slug, expires, searchParams.get("signature") ?? "")) {
return new Response("Invalid signature.", { status: 401 });
}
if (Number(expires) * 1000 < Date.now()) {
return new Response("Link expired.", { status: 401 });
}
const post = await getDraftPost(slug);
if (!post) {
return new Response("No such post.", { status: 404 });
}
(await draftMode()).enable();
redirect(`/news/${post.slug}`);
}
timingSafeEqual. The safe handler goes further: the CMS signs each link, and lines 6 to 11 compare the 32-byte HMAC it computes with the link's signature, decoded from 64 hex characters. timingSafeEqual needs two buffers of the same length, which both are.
Editors say the preview ends by itself a moment after the post opens. The banner looks like this, and /api/draft/exit calls disable(). What should Exit preview be instead of a Link?
import Link from "next/link";
import { draftMode } from "next/headers";
export async function PreviewBanner() {
const { isEnabled } = await draftMode();
if (!isEnabled) return null;
return (
<aside role="status">
<p>You are previewing drafts.</p>
<Link href="/api/draft/exit">Exit preview</Link>
</aside>
);
}
import { draftMode } from "next/headers";
import { exitPreview } from "./actions";
export async function PreviewBanner() {
const { isEnabled } = await draftMode();
if (!isEnabled) return null;
return (
<aside role="status">
<p>You are previewing drafts.</p>
<form action={exitPreview}>
<button>Exit preview</button>
</form>
</aside>
);
}
A form with a Server Action. Forms aren't prefetched, so disable() runs only when the editor clicks. The recording kept draft mode on until the click.
A visitor opens /news/spring-teas. Then the editor previews it twice and sees Our spring teas are back in March. Then another visitor opens it. What does that visitor see?
import "server-only";
import { cacheTag } from "next/cache";
import { draftMode } from "next/headers";
export type Post = { slug: string; title: string; body: string };
const cms = process.env.CMS_URL;
export async function getPublishedSlugs(): Promise<string[]> {
"use cache";
cacheTag("posts");
const response = await fetch(`${cms}/posts`);
const posts: Post[] = await response.json();
return posts.map((post) => post.slug);
}
export async function getPost(slug: string): Promise<Post | undefined> {
"use cache";
cacheTag("posts");
const { isEnabled } = await draftMode();
const response = await fetch(`${cms}/${isEnabled ? "drafts" : "posts"}/${slug}`, {
headers: isEnabled ? { authorization: `Bearer ${process.env.CMS_PREVIEW_TOKEN}` } : {},
});
return response.ok ? response.json() : undefined;
}
export async function getDraftPost(slug: string): Promise<Post | undefined> {
const response = await fetch(`${cms}/drafts/${slug}`, {
headers: { authorization: `Bearer ${process.env.CMS_PREVIEW_TOKEN}` },
});
return response.ok ? response.json() : undefined;
}
Our spring teas are sold out. The first visitor stored the published post. The editor's two runs were never saved, so the last visitor got that stored copy, and the CMS wasn't asked.
Check your understanding
Question 1 of 7An editor's browser has the
__prerender_bypasscookie. What does a visitor without it get for/news/autumn-teas?Choose an answer to see the explanation.
Question 2 of 7This Route Handler gets
/api/draft?secret=…&slug=https://evil.example/loginwith the right secret. What does it answer?Read the code, then predictTypeScriptimport { draftMode } from "next/headers"; import { redirect } from "next/navigation"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); if (searchParams.get("secret") !== process.env.DRAFT_SECRET) { return new Response("Invalid secret.", { status: 401 }); } (await draftMode()).enable(); redirect(searchParams.get("slug") ?? "/"); }Choose an answer to see the explanation.
Question 3 of 7What does the response to an editor's draft page say in its
Cache-Controlheader?Choose an answer to see the explanation.
Question 4 of 7In this
lib/cms.ts,getPostreads draft mode inside"use cache". An editor views the spring post twice in draft mode. What happens?Read the code, then predictTypeScriptimport "server-only"; import { cacheTag } from "next/cache"; import { draftMode } from "next/headers"; export type Post = { slug: string; title: string; body: string }; const cms = process.env.CMS_URL; export async function getPublishedSlugs(): Promise<string[]> { "use cache"; cacheTag("posts"); const response = await fetch(`${cms}/posts`); const posts: Post[] = await response.json(); return posts.map((post) => post.slug); } export async function getPost(slug: string): Promise<Post | undefined> { "use cache"; cacheTag("posts"); const { isEnabled } = await draftMode(); const response = await fetch(`${cms}/${isEnabled ? "drafts" : "posts"}/${slug}`, { headers: isEnabled ? { authorization: `Bearer ${process.env.CMS_PREVIEW_TOKEN}` } : {}, }); return response.ok ? response.json() : undefined; } export async function getDraftPost(slug: string): Promise<Post | undefined> { const response = await fetch(`${cms}/drafts/${slug}`, { headers: { authorization: `Bearer ${process.env.CMS_PREVIEW_TOKEN}` }, }); return response.ok ? response.json() : undefined; }Choose an answer to see the explanation.
Question 5 of 7Which part of the bypass cookie makes draft mode work on
/teastoo, not only on the post?Choose an answer to see the explanation.
Question 6 of 7The banner's Exit preview is this
<Link>to a GET Route Handler that callsdisable(). What did the recording show?Read the code, then predictTSXimport Link from "next/link"; import { draftMode } from "next/headers"; export async function PreviewBanner() { const { isEnabled } = await draftMode(); if (!isEnabled) return null; return ( <aside role="status"> <p>You are previewing drafts.</p> <Link href="/api/draft/exit">Exit preview</Link> </aside> ); }Choose an answer to see the explanation.
Question 7 of 7Why can't a visitor turn on draft mode by making a
__prerender_bypasscookie in their own browser?Choose an answer to see the explanation.
Key takeaways
- The CMS's Preview link is a password for every draft. Have the CMS sign it for one post with an expiry, check the HMAC with
timingSafeEqual, check the slug's shape and the time, ask the CMS for the post, then callenable(). - Redirect to a path built from the CMS's answer, never to the address's
slug: that's an open redirect. - Fetch drafts with the preview token only when
draftMode().isEnabledis true, in server-only code, and don't cache them. - In draft mode, Next.js renders the page for the request, never saves it, sends
private, no-cache, no-store, and runs"use cache"functions again without storing them. Visitors keep the cached pages. - In
next start, the__prerender_bypasscookie isPath=/,HttpOnly,SecureandSameSite=none(next devusesSameSite=laxwithoutSecure). It ends when the browser closes, and works only while its value matches the build's preview ID. - Leave draft mode with a form and a Server Action that calls
disable(). ALinkto a GET handler gets prefetched and ends draft mode on its own.
Remember the one-liner.
Check, then enable; fetch drafts only for draft requests; and trust Next.js to keep them out of every cache visitors read.
The cookie is set and checked in draft-mode-provider.ts, and draftMode() itself is draft-mode.ts. How cached functions treat draft mode is in use-cache-wrapper.ts, and how a page request does is in app-page-runtime.ts, all at release v16.4.3.
Coming next: Work after the response. after() runs logging and other jobs once the response has gone, so nobody waits for them.