How I rotate Sanity draft mode secrets at the edge without redeploying Next.js

By Nayan Kyada · · 6 min read

Part of The Sanity + Next.js Guide

Sanity draft mode preview on Next.js App Router works fine when your secret is baked into an environment variable — until a client accidentally pastes the preview URL into a Slack channel and you have to redeploy to rotate it. Vercel Edge Config solves that: you update a single key in the dashboard and the new secret is live within seconds, no rebuild required. Here is how to wire it up.

Why the standard env-var approach breaks down

The Next.js docs show a route handler that reads SANITY_PREVIEW_SECRET from process.env and sets the draft-mode cookie. That works for a single developer. It breaks down in two ways on real projects:

  1. Secret leaks are sticky. Changing a process.env value on Vercel only takes effect after a new deployment. Until that build finishes, the old secret still works on the live deployment.
  2. You cannot audit access. Every stakeholder shares one secret. You cannot tell which link a leaked URL came from.

Edge Config is a low-latency key-value store that Vercel replicates globally, so reads are typically very fast. Updating a value propagates globally within seconds, without a deploy.

Setting up Edge Config

In the Vercel dashboard: Storage → Create → Edge Config. Add one key:

SANITY_PREVIEW_SECRET = "<random 32-char string>"

Then connect the store to your project (Settings → Integrations on the store page). Vercel injects EDGE_CONFIG as a connection string environment variable automatically.

Install the SDK:

npm install @vercel/edge-config

The preview route handler

Create app/api/preview/route.ts. This runs on the Node.js runtime by default; I keep it there rather than the edge runtime so I can use the full Next.js draftMode() API without any caveats.

// app/api/preview/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { NextRequest, NextResponse } from 'next/server'
import { get } from '@vercel/edge-config'
 
export async function GET(req: NextRequest) {
  const { searchParams } = req.nextUrl
  const incomingSecret = searchParams.get('secret')
  const slug = searchParams.get('slug') ?? '/'
 
  // 1. Read the current secret from Edge Config — fast read, no redeploy needed
  const validSecret = await get<string>('SANITY_PREVIEW_SECRET')
 
  if (!incomingSecret || incomingSecret !== validSecret) {
    return new NextResponse('Invalid preview secret', { status: 401 })
  }
 
  // 2. Validate the slug looks like a real path before redirecting
  if (!/^\/[a-z0-9/_-]*$/i.test(slug)) {
    return new NextResponse('Invalid slug', { status: 400 })
  }
 
  // 3. Enable draft mode — Next.js sets the __prerender_bypass cookie (HttpOnly; SameSite=None + Secure outside `next dev`)
  const draft = await draftMode()
  draft.enable()
 
  // 4. Redirect to the requested page
  redirect(slug)
}

To disable preview, add a matching disable handler:

// app/api/disable-preview/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
 
export async function GET() {
  const draft = await draftMode()
  draft.disable()
  redirect('/')
}

Reading draft mode in a page and avoiding content leaks

This is where most implementations go wrong. If you forget the draftMode() check and unconditionally fetch with perspective: 'drafts' and a read token, you will serve draft content to every visitor whenever the draft-mode cookie is absent but the GROQ query still runs the wrong perspective.

// app/[slug]/page.tsx  (simplified)
import { draftMode } from 'next/headers'
import { client } from '@/lib/sanity/client'
 
const query = `*[_type == "post" && slug.current == $slug][0]{ _id, title, body }`
 
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const { isEnabled } = await draftMode()
 
  const post = await client
    .withConfig({
      // perspective switches between published CDN and draft API
      perspective: isEnabled ? 'drafts' : 'published',
      // draft fetches must bypass the CDN so editors see live changes
      useCdn: !isEnabled,
      // only pass the token when actually in draft mode
      token: isEnabled ? process.env.SANITY_API_READ_TOKEN : undefined,
    })
    .fetch(query, { slug })
 
  if (!post) return <div>Not found</div>
 
  return <article>{post.title}</article>
}

Key constraints:

  • perspective: 'published' + useCdn: true — fast, cached, no token needed for published content.
  • perspective: 'drafts' (formerly previewDrafts) + useCdn: false — bypasses CDN, hits the Sanity API directly, requires a read token scoped to draft access.
  • The read token (SANITY_API_READ_TOKEN) lives in Vercel environment variables, not Edge Config. It is a long-lived credential; the preview secret is what you rotate frequently.

Cookie security and same-site issues

Outside of next dev, Next.js sets the __prerender_bypass cookie with SameSite=None; Secure (in development it uses SameSite=Lax without Secure, so plain http://localhost works). That means a production build only works over HTTPS. If you test a production build locally, or embed the site in the Studio's iframe from another origin, either:

  • Use HTTPS via a tunnelling tool (ngrok, Cloudflare Tunnel), or
  • Run next dev --experimental-https, which generates a self-signed cert automatically.

Also, the bypass cookie has no expiry by default — it persists for the browser session. If you want it to expire after, say, 2 hours for security, you cannot do that from the route handler directly (Next.js owns the cookie). The practical workaround: issue your own short-lived session cookie alongside it and check both in proxy.ts (called middleware.ts before Next.js 16).

Secret rotation in production

To rotate the secret: open Vercel → Storage → your Edge Config store → edit SANITY_PREVIEW_SECRET. Change the value, save. Within seconds, all preview links using the old secret return 401. No deployment, no downtime for public visitors, no cache flush required.

For multi-client setups you can store an array of secrets and accept any of them with a short overlap window, then remove the old one 30 minutes later. That gives editors time to finish a session after rotation without getting locked out mid-review.

Wiring Sanity Studio to the preview URL

One important caveat: Sanity's Presentation tool does not use your Edge Config secret. It generates its own short-lived secret, stores it in your dataset, and appends it to the enable URL. The enable route for Presentation should validate that secret — next-sanity provides defineEnableDraftMode for this — rather than the Edge Config one. The Edge Config pattern above is for preview links you hand out manually (to stakeholders outside the Studio).

In your Sanity Studio config, point Presentation at a dedicated enable route:

// sanity.config.ts
import { defineConfig } from 'sanity'
import { presentationTool } from 'sanity/presentation'
 
export default defineConfig({
  // ...
  plugins: [
    presentationTool({
      previewUrl: {
        previewMode: {
          enable: '/api/draft-mode/enable',
        },
      },
    }),
  ],
})

Presentation handles its own secret automatically, so you do not need to hard-code secrets in the Studio config.

The pattern above — Edge Config for the rotating shareable-link secret, Presentation's built-in secret for in-Studio preview, a scoped read token for the Sanity API, and a strict perspective check in every page — is the combination that keeps draft content off your public CDN while still giving editors a fast, reliable preview link they can share with stakeholders without you having to babysit deployments.