How I set up Sanity draft mode preview with Next.js App Router and Vercel Edge Config
Architecting Zero-Leak Draft Previews: Next.js App Router Meets Vercel Edge Config
Current Situation Analysis
Content preview systems are frequently treated as a secondary concern during Next.js development, which creates a dangerous gap between development convenience and production security. When teams implement draft previews, they typically assume the workflow is straightforward: validate a token, flip a switch, and render unpublished content. In reality, this assumption ignores three critical infrastructure realities:
- Runtime Cookie Behavior: Next.js
draftMode()relies on HTTP-only cookies to persist session state across requests. The Edge runtime'sResponseCookiesimplementation has known inconsistencies when handling redirects across different Vercel edge regions, causing preview sessions to silently drop. - CDN Cache Contamination: Sanity's hosted CDN explicitly excludes documents with
drafts.*prefixes. If developers bypass the CDN globally for convenience, or bake thepreviewDraftsperspective into a shared client instance, unpublished content can leak into cached responses, pollute search engine indexes, and expose internal drafts to the public. - Secret Management Friction: Hardcoding preview tokens in
.envfiles or repository variables creates deployment bottlenecks. Rotating a compromised token requires a full application redeploy, introducing downtime and operational drag.
These issues are overlooked because preview workflows are often tested in isolation. Developers verify that the preview button works locally, but rarely stress-test cookie persistence across edge nodes, validate redirect targets against open-redirect vulnerabilities, or simulate token rotation under production load. The result is a system that functions in development but introduces security, SEO, and operational risks at scale.
Vercel Edge Config resolves the secret management bottleneck by providing a globally replicated key-value store with sub-millisecond read latency. Combined with Next.js App Router's server-side rendering model and Sanity's perspective-based API, teams can build a preview architecture that isolates draft data, enforces strict validation, and enables zero-downtime credential rotation.
WOW Moment: Key Findings
The architectural trade-offs between common preview implementations become stark when measured against production reliability metrics. The following comparison isolates the critical differences between legacy patterns and a hardened Edge Config-driven approach.
| Approach | Secret Rotation Friction | Cookie/Redirect Reliability | Production Leak Risk | Infrastructure Overhead |
|---|---|---|---|---|
| Hardcoded Env + Edge Runtime | High (requires redeploy) | Unstable across regions | High (global client contamination) | Low |
| Edge Config + Node Runtime + Conditional Fetch | Near-zero (~5s propagation) | Stable (Node ResponseCookies) |
Near-zero (scoped perspectives) | Low |
| Full Client-Side Iframe Preview | Medium (Studio env vars) | N/A (isolated context) | Medium (CSP/frame vulnerabilities) | High (dual rendering) |
Why this matters: The Edge Config + Node Runtime pattern eliminates the three most common failure modes in preview systems: deployment friction, session persistence drops, and content leakage. By decoupling secret storage from application code and enforcing runtime-specific cookie behavior, teams gain a preview workflow that scales securely across global edge networks without compromising cache integrity or search engine visibility.
Core Solution
Building a production-grade preview system requires isolating three concerns: secret validation, session management, and data fetching. The following architecture enforces strict boundaries between these layers while leveraging Next.js App Router capabilities.
Step 1: Edge Config Secret Retrieval
Vercel Edge Config stores the preview token outside the application bundle. This enables instant rotation without rebuilding the Next.js application.
// lib/edge-config.ts
import { createClient } from '@vercel/edge-config';
export const edgeConfigStore = createClient(
process.env.EDGE_CONFIG_CONNECTION_STRING!
);
export async function getPreviewToken(): Promise<string | undefined> {
return edgeConfigStore.get<string>('PREVIEW_AUTH_TOKEN');
}
Rationale: Storing the token in Edge Config removes it from version control and deployment artifacts. The createClient wrapper abstracts the connection string, keeping environment configuration centralized.
Step 2: Path Validation & Security Gate
Open redirects are a common vulnerability in preview handlers. Strict path validation prevents attackers from hijacking the redirect flow.
// lib/validators.ts
export function isValidPreviewPath(path: string | null): path is string {
if (!path || !path.startsWith('/')) return false;
const safePathRegex = /^\/[a-zA-Z0-9\-._~:/\[\]@!$&'()*+,;=%?]*$/;
return safePathRegex.test(path);
}
Rationale: The regex enforces RFC 3986 compliant URI characters while rejecting query-only strings or protocol-relative paths. This blocks open-redirect attacks without over-constraining valid slug formats.
Step 3: Activation Route Handler
The activation endpoint validates the token, confirms document existence, enables draft mode, and redirects. It must run on the Node runtime to guarantee cookie persistence.
// app/api/content/preview/activate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { getPreviewToken } from '@/lib/edge-config';
import { isValidPreviewPath } from '@/lib/validators';
import { sanityClient } from '@/lib/sanity/client';
import { defineQuery } from 'groq';
export const runtime = 'nodejs';
const documentExistsQuery = defineQuery(
`*[_type == $contentType && slug.current == $pathSegment][0]{ _id }`
);
export async function GET(request: NextRequest) {
const params = request.nextUrl.searchParams;
const submittedToken = params.get('token');
const targetPath = params.get('path');
const contentType = params.get('type') ?? 'article';
// 1. Token validation against Edge Config
const validToken = await getPreviewToken();
if (!submittedToken || submittedToken !== validToken) {
return new NextResponse('Unauthorized', { status: 401 });
}
// 2. Path security validation
if (!isValidPreviewPath(targetPath)) {
return new NextResponse('Invalid target path', { status: 400 });
}
// 3. Document existence verification
const exists = await sanityClient.fetch(
documentExistsQuery,
{ contentType, pathSegment: targetPath.replace(/^\//, '') },
{ perspective: 'previewDrafts', useCdn: false }
);
if (!exists) {
return new NextResponse('Content not found', { status: 404 });
}
// 4. Enable draft session
const session = await draftMode();
session.enable();
// 5. Secure redirect
redirect(targetPath);
}
Rationale:
runtime = 'nodejs'ensuresdraftMode().enable()sets cookies reliably across all Vercel regions.- Document existence check prevents redirect loops and catches malformed preview URLs early.
useCdn: falseduring validation guarantees we're checking the live API, not a stale cache.
Step 4: Deactivation Route Handler
// app/api/content/preview/deactivate/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export const runtime = 'nodejs';
export async function GET() {
const session = await draftMode();
session.disable();
redirect('/');
}
Rationale: Separating deactivation into its own route simplifies cleanup logic and prevents accidental token exposure during exit flows.
Step 5: Conditional Data Fetching in RSC
Page components must dynamically switch between published and draft perspectives based on session state. Baking the perspective into a shared client causes cache pollution.
// app/(marketing)/[slug]/page.tsx
import { draftMode } from 'next/headers';
import { sanityClient } from '@/lib/sanity/client';
import { defineQuery } from 'groq';
import type { ArticleData } from '@/types/sanity';
const articleQuery = defineQuery(
`*[_type == "article" && slug.current == $slug][0]{
title, publishedAt, body[]{ ..., asset->{ url, altText } }
}`
);
export default async function ArticlePage({ params }: { params: { slug: string } }) {
const { isEnabled } = await draftMode();
const fetchConfig = isEnabled
? { perspective: 'previewDrafts', useCdn: false, token: process.env.SANITY_PREVIEW_TOKEN }
: { perspective: 'published', useCdn: true };
const article = await sanityClient.fetch<ArticleData>(
articleQuery,
{ slug: params.slug },
fetchConfig
);
if (!article) return <div className="p-8">Content unavailable</div>;
return (
<article>
{isEnabled && (
<header className="fixed top-0 inset-x-0 z-50 bg-amber-500 px-6 py-3 text-sm font-semibold">
Preview Mode Active —
<a href="/api/content/preview/deactivate" className="underline ml-2">Exit</a>
</header>
)}
<h1 className="mt-16 text-4xl font-bold">{article.title}</h1>
{/* Render body... */}
</article>
);
}
Rationale:
draftMode().isEnabledis checked server-side, ensuring the perspective switch happens before hydration.- The preview token is only injected when
isEnabledis true, preventing accidental exposure in public CDN responses. - The banner is server-rendered, eliminating layout shift and ensuring it never appears in cached public builds.
Pitfall Guide
1. Runtime Mismatch for Draft Cookies
Explanation: Using runtime = 'edge' in preview route handlers causes draftMode() cookies to drop during redirects on certain Vercel edge nodes. The Edge runtime's cookie serialization differs from Node's, breaking session persistence.
Fix: Explicitly declare export const runtime = 'nodejs' in all preview activation/deactivation routes.
2. Global Perspective Contamination
Explanation: Setting perspective: 'previewDrafts' in a shared Sanity client module causes all fetches to return draft content, even when draft mode is disabled. This leaks unpublished data to production caches.
Fix: Always pass the perspective per-fetch based on draftMode().isEnabled. Never mutate the base client configuration.
3. Unvalidated Redirect Targets
Explanation: Accepting arbitrary slug or path query parameters without validation enables open-redirect attacks. Attackers can craft preview URLs that redirect users to malicious domains after cookie injection.
Fix: Implement strict path validation using RFC-compliant regex patterns. Reject any path that doesn't start with / or contains protocol schemes.
4. CDN Bypass in Production Builds
Explanation: Disabling useCdn globally to "speed up development" removes Sanity's edge caching layer in production. This increases API latency, exhausts rate limits, and bypasses published-content optimization.
Fix: Keep useCdn: true as the default. Only disable it conditionally when draftMode().isEnabled is true.
5. Missing Frame Ancestor Restrictions
Explanation: If your Sanity Studio or preview routes lack X-Frame-Options or frame-ancestors CSP directives, attackers can embed your draft preview in phishing pages. Users may unknowingly interact with unpublished content in a malicious context.
Fix: Apply Content-Security-Policy: frame-ancestors 'self' to all preview-related routes. Block cross-origin framing entirely.
6. Assuming Synchronous Edge Config Propagation
Explanation: Vercel Edge Config propagates globally in approximately 5 seconds, not instantly. During rotation, some edge nodes may still serve the old token while others serve the new one.
Fix: Implement a dual-token validation window during rotation. Accept both PREVIEW_AUTH_TOKEN and PREVIEW_AUTH_TOKEN_PREV for a 60-second overlap, then purge the legacy key.
7. Token Scope Leakage
Explanation: Passing the Sanity preview token to client-side components or embedding it in public API routes exposes it to browser devtools and network logs.
Fix: Keep the preview token strictly within server-side fetch calls. Never expose it to use client components or public environment variables.
Production Bundle
Action Checklist
- Create Vercel Edge Config store and add
PREVIEW_AUTH_TOKENkey - Set
EDGE_CONFIG_CONNECTION_STRINGin project environment variables - Declare
runtime = 'nodejs'in all preview route handlers - Implement strict path validation before any redirect operation
- Conditionally apply
perspective: 'previewDrafts'based ondraftMode().isEnabled - Restrict
frame-ancestorsCSP directive to'self'on preview routes - Verify token rotation using dual-key validation window
- Audit all Sanity fetch calls to ensure
useCdndefaults totrue
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Small team, single region | Hardcoded env + Node runtime | Simpler setup, lower operational overhead | None |
| Multi-region deployment, frequent content updates | Edge Config + Node runtime + Conditional fetch | Zero-downtime rotation, consistent cookie behavior, cache isolation | Edge Config: $0-$50/mo depending on read volume |
| High-security enterprise, compliance requirements | Edge Config + Dual-token rotation + Strict CSP + Audit logging | Meets SOC2/ISO controls, prevents token leakage, enables forensic tracking | Edge Config + logging infrastructure: ~$100/mo |
| Static marketing site, rare drafts | Client-side iframe preview | Isolates draft context, avoids server-side cookie complexity | Higher bundle size, dual rendering overhead |
Configuration Template
// lib/sanity/client.ts
import { createClient } from 'next-sanity';
export const sanityClient = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2024-01-01',
useCdn: true,
perspective: 'published',
token: process.env.SANITY_READ_TOKEN,
});
// middleware.ts (optional: enforce CSP on preview routes)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api/content/preview')) {
const response = NextResponse.next();
response.headers.set(
'Content-Security-Policy',
"frame-ancestors 'self'; default-src 'self'"
);
return response;
}
return NextResponse.next();
}
export const config = {
matcher: '/api/content/preview/:path*',
};
Quick Start Guide
- Initialize Edge Config: Navigate to your Vercel project dashboard β Edge Config β Create Store. Add key
PREVIEW_AUTH_TOKENwith a cryptographically random 32-character string. - Wire Environment Variables: Add
EDGE_CONFIG_CONNECTION_STRINGto your project settings using the store's connection URL. Install@vercel/edge-configvia npm. - Deploy Route Handlers: Copy the activation and deactivation route templates into
app/api/content/preview/. Ensureruntime = 'nodejs'is declared in both files. - Update Sanity Studio: In your Sanity desk structure, configure the preview URL to point to
/api/content/preview/activate?token=${token}&path=${slug}. Pass the token via Studio environment variables, not Next.js. - Verify Session Flow: Trigger a preview from Sanity Studio. Confirm the
__prerender_bypasscookie is set, draft content renders, and exiting preview clears the session without cache contamination.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
