Sanity Draft Mode Without Sacrificing Edge Performance
By Nayan Kyada · · 7 min read
Part of The Sanity + Next.js Guide
The problem with naive draft implementations
Most Sanity + Next.js tutorials show you draftMode() from next/headers and call it a day. You check the cookie, swap your GROQ query to include drafts, and suddenly every page request hits the Sanity CDN at runtime. Your published pages lose edge caching. Your TTFB jumps because you're waiting on a Sanity round-trip for every visitor.
On projects where editors need live preview but the public site has to stay fast, here's the pattern I use to keep published pages on the edge while draft mode works for authenticated users.
Separate data-fetching functions by draft state
Instead of one function that branches on draftMode().isEnabled, I write two:
// app/lib/sanity/queries.ts
import { client } from './client';
import { draftMode } from 'next/headers';
const PUBLISHED_QUERY = `*[_type == "post" && slug.current == $slug][0] {
title, body, publishedAt, "image": mainImage.asset->url
}`;
const DRAFT_QUERY = `*[_type == "post" && slug.current == $slug] | order(_updatedAt desc) [0] {
title, body, publishedAt, "image": mainImage.asset->url, _updatedAt
}`;
export async function getPublishedPost(slug: string) {
return client.fetch(PUBLISHED_QUERY, { slug }, {
next: { revalidate: 3600, tags: [`post:${slug}`] }
});
}
export async function getDraftPost(slug: string) {
return client.fetch(DRAFT_QUERY, { slug }, {
perspective: 'drafts',
useCdn: false,
token: process.env.SANITY_API_READ_TOKEN, // server-only Viewer token; drafts need auth
// No revalidate — always fresh
});
}The published function uses ISR with a one-hour window and on-demand tags. The draft function hits the Sanity API directly, no CDN, with a server-only read token and perspective: 'drafts' to see unpublished changes. ('drafts' replaced the older 'previewDrafts' name in recent API versions.)
Now in your page component:
// app/posts/[slug]/page.tsx
import { draftMode } from 'next/headers';
import { getPublishedPost, getDraftPost } from '@/lib/sanity/queries';
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const { isEnabled: draft } = await draftMode();
const post = draft
? await getDraftPost(slug)
: await getPublishedPost(slug);
if (!post) return <div>Not found</div>;
return (
<article>
<h1>{post.title}</h1>
{draft && <div className="draft-banner">Draft mode active</div>}
{/* render body */}
</article>
);
}Toggling draft mode from Sanity Studio
For a simple setup, add a "Preview" button in the document action bar. Document actions are functions that receive the document and return the action description. In sanity.config.ts:
// sanity.config.ts
import { defineConfig, type DocumentActionComponent } from 'sanity';
import { structureTool } from 'sanity/structure';
import { visionTool } from '@sanity/vision';
import schemas from './schemas';
const PreviewAction: DocumentActionComponent = (props) => {
const doc = props.draft ?? props.published;
const slug = (doc?.slug as { current?: string } | undefined)?.current;
return {
label: 'Preview',
disabled: !slug,
onHandle: () => {
// SANITY_STUDIO_* variables are bundled into the Studio, so anyone with Studio access can read them.
const previewUrl = `${process.env.SANITY_STUDIO_SITE_URL}/api/draft?secret=${process.env.SANITY_STUDIO_PREVIEW_SECRET}&slug=/posts/${slug}`;
window.open(previewUrl, '_blank');
},
};
};
export default defineConfig({
projectId: 'abc123',
dataset: 'production',
plugins: [
structureTool(),
visionTool(),
],
schema: { types: schemas },
document: {
actions: (prev, { schemaType }) => {
if (schemaType === 'post') {
return [...prev, PreviewAction];
}
return prev;
},
},
});The route handler at /api/draft/route.ts:
// app/api/draft/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';
export async function GET(req: NextRequest) {
const secret = req.nextUrl.searchParams.get('secret');
const slug = req.nextUrl.searchParams.get('slug');
if (secret !== process.env.SANITY_PREVIEW_SECRET || !slug) {
return new Response('Invalid request', { status: 401 });
}
const dm = await draftMode();
dm.enable();
redirect(slug); // validate this first — see "Securing the draft endpoint" below
}If you're using Sanity's Presentation tool, defineEnableDraftMode from next-sanity/draft-mode replaces this hand-written route: it verifies the request came from an authenticated Studio session, so you don't need a shared secret at all.
Now editors click "Preview" in Studio, the cookie is set, they see draft content. Public visitors never trigger that code path.
Why this keeps edge performance intact
Published requests hit the edge function, read from Next.js Data Cache (or Vercel's edge cache if you're on their platform), never wait on Sanity. Draft requests bypass all caching, but only editors see them. You measure your P95 TTFB on production traffic, not internal previews.
I've seen projects where devs enabled draft mode globally during development and forgot to turn it off. Suddenly every page became dynamic. This pattern makes the split explicit in code.
On-demand revalidation from webhooks
When an editor publishes a post, I revalidate the specific tag:
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(req: NextRequest) {
const body = await req.json();
const slug = body.slug;
if (!slug) return new Response('Missing slug', { status: 400 });
// In production, verify the webhook signature first (e.g. parseBody from next-sanity/webhook)
revalidateTag(`post:${slug}`, 'max');
return new Response('Revalidated', { status: 200 });
}Sanity webhook (configured in project settings) hits this endpoint on publish. The edge cache updates within seconds. No need for editors to manually purge.
Where this falls short
If you have deeply nested Sanity references (post → author → author's recent posts), the draft query can get expensive. Expanding four levels of references can make a draft query noticeably slow. In those cases I either simplify the preview (show fewer fields) or accept the cost — it's only for editors.
Also, if your site has user-generated content (comments, likes) that changes constantly, ISR might not fit. But for marketing sites, docs, blogs — this pattern means adding live preview doesn't require re-architecting the site.
Securing the draft endpoint properly
The /api/draft route is an authenticated door into unpublished content, so it deserves more care than the copy-paste implementations it usually gets. Three rules I apply on every project:
Compare secrets in constant time. A === string comparison leaks timing information. Use crypto.timingSafeEqual on equal-length buffers:
import { timingSafeEqual } from "node:crypto";
function safeCompare(a: string, b: string) {
const ba = Buffer.from(a), bb = Buffer.from(b);
return ba.length === bb.length && timingSafeEqual(ba, bb);
}Validate the redirect target. The slug param that tells the route where to send the editor after enabling draft mode is attacker-controlled input. Only allow relative paths that match your route patterns — otherwise the endpoint doubles as an open redirect:
if (!slug?.startsWith("/") || slug.startsWith("//")) {
return new Response("Invalid path", { status: 400 });
}Keep the secret out of the URL where you can. Query strings end up in access logs, browser history, and Referer headers. For Studio-initiated preview the secret can travel in a header; if it must be a query param (Presentation tool constraints), rotate it periodically and keep it in an environment variable so rotation doesn't need a code change. Better still, use the Presentation tool with defineEnableDraftMode, which avoids a shared secret entirely.
Testing draft behaviour in CI
Draft mode bugs are embarrassing in both directions: editors who can't preview, or worse, draft content leaking to production. A cheap Playwright check covers both:
test("draft content requires the cookie", async ({ request }) => {
const res = await request.get("/blog/known-draft-only-post");
expect(res.status()).toBe(404); // no cookie → no draft
});
test("draft endpoint rejects bad secrets", async ({ request }) => {
const res = await request.get("/api/draft?secret=wrong&slug=/");
expect(res.status()).toBe(401);
});Two requests, runs in seconds, and it catches the kind of regression that matters — for example, a refactor that accidentally makes sanityFetch default to the preview client. The cost of the test is nothing next to the cost of that reaching production.
Frequently asked questions
01Does Sanity draft mode slow down the published site for regular visitors?
Not if you split your data-fetching into two functions instead of one that branches on `isEnabled`. Published requests keep using ISR with tag-based revalidation and stay on the edge; only requests with the draft-mode cookie hit the Sanity API directly with `perspective: 'drafts'`, a read token, and no CDN. Public visitors never trigger the uncached path — see the two-function pattern below.
02How do I secure a Sanity draft-mode preview route from being an open redirect?
Validate that the `slug` query param is a relative path matching your route patterns before redirecting to it — reject anything starting with `//` or not starting with `/`. Also compare the preview secret with `crypto.timingSafeEqual` rather than `===`, since a naive string comparison leaks timing information an attacker can use to guess the secret byte by byte.