Securing Draft Mode Routes in Next.js

Draft mode renders unpublished content on the production hostname, which makes it genuinely useful for editors and genuinely dangerous the moment entering it is as easy as appending a query string.

When to Use This Approach

Secure draft mode when:

  • Editors preview unpublished entries on the live domain rather than on a separate preview host.
  • The current entry mechanism uses a shared static secret that has never been rotated.
  • Draft links are shared for review with people outside the editorial team.

The Grant Model

The safe design has three separable parts: a link that proves authorisation, a cookie that carries it, and a render that trusts only the cookie. Collapsing any two of them is where leaks come from.

Signed link, scoped cookie, cookie-only render An editor follows a signed link to an entry route that validates the signature and sets a cookie, then loads the page which renders drafts only when the cookie is present; an anonymous request without the cookie receives published content. editor opens signed link /api/draft verify HMAC + expiry set cookie HttpOnly, 1 hour page renders draft content crawler or shared link published content only no cookie — nothing to grant The page never inspects the URL; it only asks whether the cookie is present

Implementation Steps

Step 1: Sign the entry link

The CMS generates the link, so the signature is produced where the authorisation decision is made.

// lib/draft-link.js — used by the CMS preview button
import { createHmac } from 'node:crypto';

export function draftLink(slug, ttlMs = 60 * 60 * 1000) {
  const exp = Date.now() + ttlMs;
  const sig = createHmac('sha256', process.env.PREVIEW_SECRET)
    .update(`${slug}:${exp}`)
    .digest('hex');
  const qs = new URLSearchParams({ slug, exp: String(exp), sig });
  return `https://example.com/api/draft?${qs}`;
}

SEO impact: A leaked link is scoped to one entry and expires within the hour, so its blast radius is bounded rather than permanent.

Validation: Generate a link, wait past the expiry, and confirm it no longer works.

// app/api/draft/route.js
import { draftMode } from 'next/headers';
import { createHmac, timingSafeEqual } from 'node:crypto';

export async function GET(request) {
  const { searchParams, origin } = new URL(request.url);
  const slug = searchParams.get('slug') ?? '';
  const exp = Number(searchParams.get('exp') ?? 0);
  const sig = searchParams.get('sig') ?? '';

  if (!slug || !exp || !sig) return new Response('Missing parameters', { status: 400 });
  if (Date.now() > exp) return new Response('Link expired', { status: 410 });

  const expected = createHmac('sha256', process.env.PREVIEW_SECRET)
    .update(`${slug}:${exp}`)
    .digest('hex');

  const a = Buffer.from(sig, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return new Response('Invalid signature', { status: 401 });
  }

  const entry = await cms.posts.get(slug, { draft: true });
  if (!entry) return new Response('No such entry', { status: 404 });

  (await draftMode()).enable();
  return Response.redirect(new URL(`/blog/${slug}`, origin), 307);
}

SEO impact: The only way into draft mode is a valid signature, and the grant lands in a cookie that a crawler will not carry to the next request.

Validation: curl -s -o /dev/null -w '%{http_code}\n' 'https://example.com/api/draft?slug=x' returns 400, and a tampered signature returns 401.

// app/blog/[slug]/page.jsx
import { draftMode } from 'next/headers';

export async function generateMetadata({ params }) {
  const { isEnabled } = await draftMode();
  const post = await cms.posts.get(params.slug, { draft: isEnabled });
  return {
    title: isEnabled ? `[DRAFT] ${post.title}` : post.title,
    robots: isEnabled
      ? { index: false, follow: false, nocache: true }
      : { index: true, follow: true },
  };
}

export default async function Post({ params }) {
  const { isEnabled } = await draftMode();
  const post = await cms.posts.get(params.slug, { draft: isEnabled });
  return (
    <article>
      {isEnabled && <p className="draft-banner">Previewing unpublished content</p>}
      <h1>{post.title}</h1>
    </article>
  );
}

SEO impact: A draft render always carries noindex, and the visible banner makes an accidental screenshot or shared page obvious to a human reviewer as well.

Validation: Load the page with and without the cookie and confirm the title prefix and the robots directive both change.

Step 4: Keep draft responses out of every cache

Next.js bypasses its own caches in draft mode; it says nothing about your CDN.

// middleware.js
export function middleware(request) {
  const res = NextResponse.next();
  if (request.cookies.has('__prerender_bypass')) {
    res.headers.set('Cache-Control', 'private, no-store, max-age=0, must-revalidate');
    res.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive');
    res.headers.set('Vary', 'Cookie');
  }
  return res;
}

SEO impact: Prevents the worst outcome in this area — a shared edge cache storing a draft render and serving it to anonymous visitors, and possibly to a crawler.

Validation: curl -sI --cookie '__prerender_bypass=1' https://example.com/blog/my-post | grep -iE 'cache-control|vary|x-robots'.

Step 5: Add an exit route and rotate the secret

// app/api/draft/exit/route.js
import { draftMode } from 'next/headers';

export async function GET(request) {
  (await draftMode()).disable();
  return Response.redirect(new URL('/', request.url), 307);
}

Validation: After visiting the exit route, the same page returns published content. Rotate PREVIEW_SECRET on a schedule; every outstanding link becomes invalid immediately, which is the point.

Verifying the Anonymous Path

Every check that matters is a negative one — proving that a request without the cookie sees nothing it should not.

Negative checks that prove draft isolation Four verification checks — anonymous page fetch, expired link, tampered signature, and cache header inspection — each paired with the result that indicates correct isolation. check expected result fetch page with no cookies published body, index directive follow an expired draft link 410 Gone, no cookie set alter one character of the signature 401, constant-time comparison inspect headers on a draft render private no-store, Vary Cookie A passing positive test proves nothing here — only the negatives do

SEO Impact Summary

A leaked link is bounded in two dimensions — which entry it grants and for how long — and both bounds come from the signature rather than from any secret.

Blast radius of a leaked draft link A static preview secret exposing every draft indefinitely, against a signed link exposing one entry for one hour. static secret every draft forever signed link one entry one hour Leaks are inevitable; the design question is how much one leak costs
Signal What improves What breaks if misconfigured
Duplicate control Unpublished versions never become a second indexable copy of a URL A cached draft served anonymously creates a duplicate at the canonical URL itself
Canonical selection The published version is the only candidate Google ever sees A draft indexed at the live URL suppresses the published page with no obvious cause
Content confidentiality Embargoed content stays embargoed even when links are forwarded A static secret in the URL grants permanent access to every draft
Cache correctness Anonymous visitors always receive the published, cacheable response A missing Vary: Cookie lets one editor’s draft poison the shared cache

Measurable signals to watch:

  • Status codes returned for expired and tampered draft links, asserted in CI.
  • Presence of Cache-Control: private on any response carrying the draft cookie.
  • Search Console coverage for content URLs, watched for unexpected noindex reports which would indicate a draft render being crawled.

Edge Cases and Gotchas

Draft mode is a site-wide cookie, not a per-entry grant Once enabled, every route renders drafts, not just the entry the link was signed for. If that matters, store the authorised slug in a second cookie and check it in the page — otherwise one link exposes the whole unpublished set.

Server actions and API routes see draft mode too A mutation triggered while draft mode is active may read draft content and write it somewhere permanent. Check isEnabled explicitly in any handler that writes.

The bypass cookie name is an implementation detail Middleware that keys on __prerender_bypass depends on a framework internal that can change across majors. Set your own companion cookie in the entry route and match on that instead, so the check survives an upgrade.

Editors will bookmark the draft URL After the grant expires the bookmark shows published content, which looks like the CMS lost their changes. Show the draft banner and, when draft mode is off but the referrer is the CMS, link back to the preview button rather than leaving them confused.

Frequently Asked Questions

Why is a preview secret in the URL not enough? A static secret never expires and travels wherever the link goes — tickets, chat, browser history. One leaked link grants permanent access to every draft. A signature scoped to a single slug with an expiry limits a leak to one entry for a few hours.

Can Googlebot end up in draft mode? Only if draft rendering can be triggered without a cookie. Googlebot does not retain cookies between requests, so a cookie-gated draft mode is unreachable by construction — it would follow the entry link, take the redirect, then fetch the destination with no cookie and see published content.

Does draft mode disable caching automatically? It bypasses the framework’s own data and route caches, but not your CDN. If the edge caches on URL alone, a draft response can be stored and served anonymously. private, no-store plus Vary: Cookie is what actually prevents it — the caching model is covered in edge caching behavior for SEO.

Should draft URLs differ from published URLs? No. The value of draft mode is that editors see the real URL with the real layout, which is what makes it a useful review. Introducing a separate draft URL reintroduces the duplicate-content problem this design exists to avoid.


Part of: Preview & Draft Content SEO

Related