cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Build 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.
  • 02
    Show 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".
  • 03
    Manage 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.

The Preview flow, from the CMS's link to the draft and backNext.js
app/api/draft/news/[slug]/teas/lib/
app/news/[slug]/page.tsx
import { 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>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/news/autumn-teas

You are previewing drafts.

Exit preview
Autumn teas

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

  1. GET/api/draft?slug=autumn-teas&expires=4102444800&signature=fc71c6a4b0d35d11d29205b0cbd2a26a59d2fe5ef450e4fc031f115c125f01bc307a whole page
  2. GET/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.

A window called CMS has a Preview button. An arrow goes to a card, /api/draft, which checks the link's signature, its expiry and the post. An arrow labeled 307 and a cookie goes to a browser window at /news/autumn-teas that shows the draft: arrive on Monday.CMSPreview/api/draftchecks signature and time307browseron Monday.with the bypass cookie
The editor clicks Preview. /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.
Real-life analogyA theater's dress rehearsal

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_bypass cookie
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
The main idea

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.ts: check a signed link, turn on, redirectNext.js
app/api/draft/news/[slug]/teas/lib/
app/api/draft/route.ts
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}`);}
Recorded from Next.js 16.4.3

GET /api/draft?slug=autumn-teas&expires=4102444800&signature=fc71c6a4b0d35d11d29205b0cbd2a26a59d2fe5ef450e4fc031f115c125f01bc307

Response headers
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
location /news/autumn-teas
set-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:

  1. The link's shape. Line 17 accepts a slug of lowercase letters, digits and dashes, and an expiry of ten digits; line 18 answers 400 for anything else, like a whole address as the slug.
  2. 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.expires with the key, and line 10 compares it with the link's signature as two 32-byte buffers with timingSafeEqual, 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 answers 401 for a made-up signature, and for a signature copied from another post's link.
  3. The time. Line 23 compares the expiry with the clock, and line 24 answers 401 Link expired.
  4. The post. Line 26 asks the CMS for the draft, and line 28 answers 404 if 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 signature before you log an address.
  • Change DRAFT_SECRET in 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/draft and getPost can 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:

The first version: a fixed secret, a plain comparison, and a redirect to the address's slugTypeScript
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") ?? "/");
}
Playground: send the Preview link somewhere else
The shop, with this version of app/api/draft/route.tsNext.js
app/api/draft/news/[slug]/teas/lib/
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);  if (searchParams.get("secret") !== process.env.DRAFT_SECRET) {    return new Response("Invalid secret.", { status: 401 });  }  (await draftMode()).enable();  redirect(searchParams.get("slug") ?? "/");}
Recorded from Next.js 16.4.3

GET /api/draft?secret=3f9a1c7e5b2d8f4a6c0e9b1d7a3f5c8e2b6d4f0a9c1e7b3d5f8a2c6e0b4d9f17&slug=https://evil.example/login307

Response headers
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
location https://evil.example/login
set-cookie __prerender_bypass=9272e01c1ef6ec9da96529de25692c42; Path=/; Secure; HttpOnly; SameSite=none

No HTML arrived: a redirect sends none

Try it yourself
redirect(slug)

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.

Both versions were built with next build from Next.js 16.4.3 and asked for /api/draft with the editors' secret and slug=https://evil.example/login. Reset goes back to the first version.

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:

lib/cms.ts: published posts are cached, drafts never areTypeScript
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", tagged posts for revalidation when an editor publishes.
  • Lines 24 to 29, getDraftPost, fetch /drafts/… with the token in an authorization header. They aren't cached, on purpose.
  • Lines 31 to 34, getPost, read isEnabled and pick one. The page only calls getPost.

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:

The editor's request, with the cookieNext.js
app/api/draft/news/[slug]/teas/lib/
lib/cms.ts
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);}
Recorded from Next.js 16.4.3

GET /news/autumn-teas200

Sent with the request
cookie __prerender_bypass=021dbd8c40a50f5d0e705e68bf17af7e
Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
Cache-Control private, no-cache, no-store, max-age=0, must-revalidate
x-nextjs-cache HIT
x-nextjs-prerender 1
X-Powered-By Next.js
Content-Type text/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.

Two rows for /news/autumn-teas. A visitor with no cookie gets the prerendered page, which says arrive soon. An editor with the cookie gets a page rendered for that request, from the CMS's draft with the preview token, which says arrive on Monday.a visitorno cookiethe prerendered pagearrive soon.the editorcookierendered now: /draftson Monday.with the preview token
The same address answers two ways. A visitor gets the page the build made. A request with the bypass cookie is rendered on the spot, and 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:

lib/cms.ts: one cached getPost for bothTypeScript
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:

One cached getPost, a visitor, the editor twice, and a visitorNext.js
app/api/draft/news/[slug]/teas/lib/
lib/cms.ts
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;}
Recorded from Next.js 16.4.3

GET /news/spring-teas200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=900, stale-while-revalidate=31535100
Content-Type text/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.

A window called stored copies holds getPost spring-teas: sold out, saved for the first visitor. The editor's call runs getPost again and gets back in March from the CMS, and a dimmed card shows that result is not stored. The visitor after the editor reads the stored copy: sold out.storedsold outfirst visitorvisitor afterthe editorback in Marchstored
In draft mode, a "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:

Draft mode and every cache
Where a copy is keptIn a draft requestFor everyone else
A prerendered pageSkipped. The page is rendered for the request and never saved for others.Visitors keep getting the page the build made.
A "use cache" functionRuns again, and its result is never stored.The stored copy from a visitor's request stays as it was.
The browser and any CDNThe 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:

app/preview-banner.tsx: a banner with Exit previewTSX
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>
  );
}
app/actions.ts: the Server Action behind itTypeScript
"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:

app/preview-banner.tsx with a Link (a problem)TSX
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>
  );
}
Playground: two ways to leave draft mode
The shop, with this version of the preview bannerNext.js
app/api/draft/news/[slug]/teas/lib/
app/preview-banner.tsx
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>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/news/autumn-teas
Autumn teas

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

  1. GET/news/autumn-teas200a whole page
Try it yourself
A form and a Server Action

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.

Both versions were built with next build from Next.js 16.4.3 and opened in real Chrome from the Preview link. Only the banner, and the Route Handler it links to, change. Reset goes back to the first version.

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:

Safe, or a problem?
  • 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 /drafts with the preview token only when isEnabled is 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
Try it yourself
0 of 8 correct

Sort each choice in a draft mode setup by whether it keeps drafts, secrets and visitors safe.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.

Exercise 1 · Warm-upName the cookie

The CMS's Preview link reaches this handler with a good signature for slug=autumn-teas. Which cookie does its 307 set?

Starter codeTypeScript
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}`);
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upThe visitor after the editor

    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?

    Starter codeTypeScript
    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);
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeClose the open redirect

      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?

      Starter codeTypeScript
      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") ?? "/");
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeCompare in constant time

        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?

        Starter codeTypeScript
        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") ?? "/");
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeDraft mode keeps turning itself off

          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?

          Starter codeTSX
          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>
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeA draft inside a cached function

            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?

            Starter codeTypeScript
            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;
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              The draft mode quiz · 7 questionsScore: first tries count
              1. Question 1 of 7An editor's browser has the __prerender_bypass cookie. What does a visitor without it get for /news/autumn-teas?

                Choose an answer to see the explanation.

              2. Question 2 of 7This Route Handler gets /api/draft?secret=…&slug=https://evil.example/login with the right secret. What does it answer?

                Read the code, then predictTypeScript
                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") ?? "/");
                }
                

                Choose an answer to see the explanation.

              3. Question 3 of 7What does the response to an editor's draft page say in its Cache-Control header?

                Choose an answer to see the explanation.

              4. Question 4 of 7In this lib/cms.ts, getPost reads draft mode inside "use cache". An editor views the spring post twice in draft mode. What happens?

                Read the code, then predictTypeScript
                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;
                }
                

                Choose an answer to see the explanation.

              5. Question 5 of 7Which part of the bypass cookie makes draft mode work on /teas too, not only on the post?

                Choose an answer to see the explanation.

              6. Question 6 of 7The banner's Exit preview is this <Link> to a GET Route Handler that calls disable(). What did the recording show?

                Read the code, then predictTSX
                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>
                  );
                }
                

                Choose an answer to see the explanation.

              7. Question 7 of 7Why can't a visitor turn on draft mode by making a __prerender_bypass cookie 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 call enable().
              • 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().isEnabled is 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_bypass cookie is Path=/, HttpOnly, Secure and SameSite=none (next dev uses SameSite=lax without Secure). 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(). A Link to 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.

              Read it in Next.js's own code

              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.

              CompleteFrontend Clear concepts. Working examples.