惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
Visual Studio Blog
罗磊的独立博客
宝玉的分享
宝玉的分享
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
V
V2EX
酷 壳 – CoolShell
酷 壳 – CoolShell
T
Tailwind CSS Blog
博客园_首页
量子位
月光博客
月光博客
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
博客园 - 司徒正美
人人都是产品经理
人人都是产品经理
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
爱范儿
爱范儿
S
SegmentFault 最新的问题
雷峰网
雷峰网
小众软件
小众软件
博客园 - 聂微东
美团技术团队
Apple Machine Learning Research
Apple Machine Learning Research
WordPress大学
WordPress大学
Jina AI
Jina AI
Hugging Face - Blog
Hugging Face - Blog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
How I set up Sanity draft mode preview with Next.js App R...
Nayan Kyada · 2026-05-27 · via DEV Community

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.