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.
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.
Step 2: Validate and exchange the link for a cookie
// 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.
Step 3: Render drafts from the cookie only
// 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.
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.
| 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: privateon any response carrying the draft cookie. - Search Console coverage for content URLs, watched for unexpected
noindexreports 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
- Keeping Preview Deployments Out of the Index — the host-level counterpart, where isolation rather than signing is the mechanism
- Configuring generateMetadata for SEO in Next.js — the metadata layer where the draft robots directive is emitted
- Edge Caching Behavior for SEO — why a shared cache is the real risk in a production-host preview