Skip to the solution
MediumFrontend9 min read

What is Draft Mode in Next.js and how does it work with a headless CMS?

1.1k views

The problem

Question presented to candidate: "Our marketing site is statically generated from a headless CMS and revalidates every hour. Editors complain they cannot see their changes before publishing. How does Draft Mode solve this, and how would you wire it up?"

What a strong answer should cover:

  • Draft Mode is a per-browser switch: a __prerender_bypass cookie that makes that browser's requests skip the static/ISR output and every data cache, while other visitors keep getting the cached pages.
  • Enabling: a Route Handler (/api/draft) that the CMS "Preview" button opens; it checks a shared secret and the slug, calls (await draftMode()).enable() and redirects to a path it built itself (no open redirect).
  • Rendering: (await draftMode()).isEnabled lets the page fetch the CMS draft endpoint (with a preview token) and show a "Preview mode" banner with an exit button that calls disable().
  • What gets bypassed: the fetch cache, 'use cache', unstable_cache and the ISR response cache; draft responses are Cache-Control: private, no-cache, no-store.
  • Security and edge cases: the secret stays server-side, the cookie value is regenerated per build, exit via POST (a prefetched GET <Link> would clear the cookie).

Clarifying questions expected:

  • "Does the CMS serve drafts from the same URL or a separate preview endpoint?" β€” decides whether the fetch code needs to branch on isEnabled.
  • "Who may preview, and how is the secret shared with the CMS?" β€” the secret check is the only gate.

Code / implementation expected: Yes β€” the enable Route Handler, a data function that branches on isEnabled, and an exit Server Action.

  • nextjs
  • draft-mode
  • cms
  • preview

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

9 min read Β· 8 sections

Read the code

Enable route, CMS data function and page with exit action
// app/api/draft/route.ts
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
import { getPost } from "@/lib/cms";

// The CMS "Preview" button opens /api/draft?secret=...&slug=hello in a new tab.
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const secret = searchParams.get("secret");
  const slug = searchParams.get("slug");
  if (secret !== process.env.DRAFT_SECRET || !slug) {
    return new Response("Invalid token", { status: 401 });
  }
  const post = await getPost(slug); // confirm the slug exists in the CMS
  if (!post) return new Response("Invalid slug", { status: 401 });
  (await draftMode()).enable();
  redirect(`/posts/${post.slug}`); // redirect to a path we built, never to raw input
}

// lib/cms.ts
import "server-only";
import { draftMode } from "next/headers";

const CMS = process.env.CMS_URL ?? "http://localhost:4165";

export type Post = { slug: string; title: string };

export async function getPost(slug: string): Promise<Post | null> {
  const { isEnabled } = await draftMode();
  const res = isEnabled
    ? await fetch(`${CMS}/preview/posts/${slug}`, { headers: { authorization: `Bearer ${process.env.CMS_PREVIEW_TOKEN}` } })
    : await fetch(`${CMS}/published/posts/${slug}`, { next: { revalidate: 3600 } });
  return res.ok ? res.json() : null;
}

// app/posts/[slug]/page.tsx
import { draftMode } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { getPost } from "@/lib/cms";

export function generateStaticParams() {
  return [{ slug: "hello" }];
}

async function exitPreview() {
  "use server";
  (await draftMode()).disable();
  redirect("/posts/hello");
}

export default async function PostPage({ params }: PageProps<"/posts/[slug]">) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();
  const { isEnabled } = await draftMode();
  return (
    <main>
      {isEnabled && (
        <aside role="status">
          Preview mode is on. <form action={exitPreview}><button>Exit preview</button></form>
        </aside>
      )}
      <h1>{post.title}</h1>
      <p id="rendered-at">rendered at {new Date().toISOString()}</p>
    </main>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 22 of 129 in Next.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Next.js questions
Up nextMedium

What is generateStaticParams in Next.js and how does it enable SSG for dynamic routes?

Continue