How I set up Sanity draft mode preview with Next.js App Router and Vercel Edge Config
May 27, 2026 · 6 min read
Sanity draft mode preview on Vercel edge sounds simple until you hit the edge runtime's cookie restrictions, a leaked preview secret in a Git repo, or a client asking why their unpublished page just appeared in Google Search Console. This post walks through the exact setup I ship on production projects: a typed route handler, an HttpOnly cookie, and Vercel Edge Config so you can rotate the secret without a redeploy.
How Sanity draft mode preview actually works in Next.js App Router
Next.js ships a built-in Draft Mode API (draftMode() from next/headers). When it is enabled for a request, fetch cache is bypassed and you can make authenticated Sanity queries that include _id values starting with drafts.. Sanity's hosted CDN never serves drafts — you query the API directly with a token. The flow is:
- Editor clicks the preview URL (usually
https://your-site.vercel.app/api/preview?secret=XXX&slug=/some-page). - Your route handler validates the secret, calls
draftMode().enable(), and redirects to the slug. - Every subsequent RSC fetch on that session skips the CDN and hits the Sanity API with a
Bearertoken. - Editor clicks "Exit preview" → route handler calls
draftMode().disable()and redirects home.
The risk is step 2. If the secret is hard-coded in an env var that lives in a .env.local file committed to Git, or if the route handler redirects to an attacker-controlled URL, you have a problem. Edge Config solves the first; strict slug validation solves the second.
Storing the preview secret in Vercel Edge Config
Vercel Edge Config is a globally replicated key-value store with sub-1 ms read latency at the edge. You read it with @vercel/edge-config. The point is that you can update the secret in the Vercel dashboard and every edge node picks it up in seconds — no redeploy.
Create an Edge Config store in your Vercel project dashboard, then add a key:
PREVIEW_SECRET = "some-random-32-char-string"Add the connection string to your project env vars:
EDGE_CONFIG="https://edge-config.vercel.com/ecfg_xxx?token=yyy"Install the package:
// terminal
npm i @vercel/edge-configNow never put the actual secret string in SANITY_PREVIEW_SECRET inside your codebase. Edge Config is the source of truth.
The preview route handler
Create app/api/preview/route.ts. This runs on Node runtime (not edge) because draftMode() sets a cookie via the Node response, and ResponseCookies on the edge runtime does not persist across redirects in all Vercel regions as of May 2026.
// app/api/preview/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { NextRequest, NextResponse } from 'next/server';
import { get as getEdgeConfig } from '@vercel/edge-config';
import { client } from '@/sanity/lib/client';
import { defineQuery } from 'groq';
export const runtime = 'nodejs';
const slugExistsQuery = defineQuery(
`*[_type == $type && slug.current == $slug && !(_id in path("drafts.**"))][0]{ _id }`
);
function isSafeSlug(slug: string | null): slug is string {
if (!slug) return false;
// Must start with / and contain only safe URL characters
return /^\/[a-zA-Z0-9\-._~:/\[\]@!$&'()*+,;=%?]*$/.test(slug);
}
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
const type = searchParams.get('type') ?? 'page';
// 1. Validate secret against Edge Config — no hard-coded string here
const expectedSecret = await getEdgeConfig<string>('PREVIEW_SECRET');
if (!secret || secret !== expectedSecret) {
return new NextResponse('Invalid token', { status: 401 });
}
// 2. Validate slug shape before using it in a redirect
if (!isSafeSlug(slug)) {
return new NextResponse('Missing or invalid slug', { status: 400 });
}
// 3. Optional: confirm the document actually exists (catches typos in preview URLs)
const doc = await client.fetch(
slugExistsQuery,
{ type, slug },
{ perspective: 'previewDrafts', useCdn: false }
);
if (!doc) {
return new NextResponse(`No document found for slug "${slug}"`, { status: 404 });
}
// 4. Enable draft mode — this sets the __prerender_bypass cookie
const draft = await draftMode();
draft.enable();
// 5. Redirect to the validated slug only
redirect(slug);
}
export async function GET_EXIT(req: NextRequest) {
const draft = await draftMode();
draft.disable();
redirect('/');
}Add a separate exit handler at app/api/preview/exit/route.ts:
// app/api/preview/exit/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export const runtime = 'nodejs';
export async function GET() {
const draft = await draftMode();
draft.disable();
redirect('/');
}Reading draft mode in a page component
In any RSC page that should render live drafts, check draftMode().isEnabled and swap the Sanity client perspective accordingly:
// app/(site)/[slug]/page.tsx
import { draftMode } from 'next/headers';
import { client } from '@/sanity/lib/client';
import { defineQuery } from 'groq';
import type { PageQueryResult } from '@/sanity/types';
const pageQuery = defineQuery(
`*[_type == "page" && slug.current == $slug][0]{
_id, title, _updatedAt,
body[]{ ..., asset->{ url, metadata } }
}`
);
export default async function Page({ params }: { params: { slug: string } }) {
const { isEnabled } = await draftMode();
const data = await client.fetch<PageQueryResult>(
pageQuery,
{ slug: params.slug },
isEnabled
? { perspective: 'previewDrafts', useCdn: false, token: process.env.SANITY_API_READ_TOKEN }
: { perspective: 'published', useCdn: true }
);
if (!data) return <p>Not found</p>;
return (
<main>
{isEnabled && (
<div className="fixed top-0 left-0 right-0 z-50 bg-yellow-400 px-4 py-2 text-sm font-medium">
Draft mode active —{' '}
<a href="/api/preview/exit" className="underline">Exit preview</a>
</div>
)}
<h1>{data.title}</h1>
</main>
);
}Two things to notice: the SANITY_API_READ_TOKEN is only ever passed when isEnabled is true — it never leaks into cached CDN responses. And the yellow banner is server-rendered, so it never causes CLS on the public site.
Avoiding preview content leaks
The three ways I have seen preview content leak into production:
1. useCdn: false without checking isEnabled — Some devs disable the CDN globally "because it's faster in dev". That means every deploy hits the Sanity API directly, and if you forget to filter drafts.* documents from your GROQ query, draft content can appear.
2. Wide perspective: 'previewDrafts' in a shared client — Never bake the perspective into the shared client module. Always pass it per-fetch based on the isEnabled flag.
3. Over-permissive Content-Security-Policy — If Sanity Studio is embedded at /studio and your CSP allows frame-src *, an attacker can iframe your draft preview inside a phishing page. Set X-Frame-Options: SAMEORIGIN or a restrictive frame-ancestors CSP directive on all preview routes.
Rotating the secret without downtime
Go to your Vercel Edge Config store, update PREVIEW_SECRET to the new value, and click Save. No redeploy. No downtime. The old secret stops working within the Edge Config propagation window (~5 s globally). Update the Sanity Studio preview URL in your desk structure to use the new secret — that lives in the Studio's env vars, not the Next.js app's.
If you want zero-downtime rotation (accept old and new secret for a 60-second window), read both PREVIEW_SECRET and PREVIEW_SECRET_PREV from Edge Config and check either. Delete PREVIEW_SECRET_PREV once all editors have new links.
Related posts
All posts →How I Handle Sanity Draft Mode Without Sacrificing Edge Performance
Apr 27, 2026 · 4 min read
A production pattern for Sanity draft previews in Next.js that keeps published pages on the edge while editors see real-time changes.
How I build Sanity Portable Text custom components in Next.js
May 16, 2026 · 6 min read
Learn how to build Sanity Portable Text custom components in Next.js — code blocks, callouts, embedded forms, and captioned images with full TypeScript types.
How I reduced Sanity webhook fanout by batching revalidation tags in a single route handler
May 09, 2026 · 5 min read
A pattern for consolidating dozens of per-document Next.js revalidations into one tagged endpoint, cutting webhook noise and deploy times by 70%.