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:

  1. Editor clicks the preview URL (usually https://your-site.vercel.app/api/preview?secret=XXX&slug=/some-page).
  2. Your route handler validates the secret, calls draftMode().enable(), and redirects to the slug.
  3. Every subsequent RSC fetch on that session skips the CDN and hits the Sanity API with a Bearer token.
  4. 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-config

Now 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 →