DEV Community

Cover image for How I Handle Sanity Draft Mode Without Sacrificing Edge Performance
Nayan Kyada
Nayan Kyada

Posted on • Edited on • Originally published at nayankyada.com

How I Handle Sanity Draft Mode Without Sacrificing Edge Performance

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 from 80ms to 350ms because you're waiting on a CDN round-trip for every visitor.

I've shipped six client projects where editors needed live preview but the public site had 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: 'previewDrafts',
    useCdn: false,
    // No revalidate — always fresh
  });
}
Enter fullscreen mode Exit fullscreen mode

The published function uses ISR with a one-hour window and on-demand tags. The draft function hits the Sanity API directly, no CDN, perspective: 'previewDrafts' to see unpublished changes.

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: { slug: string } }) {
  const draft = draftMode().isEnabled;
  const post = draft
    ? await getDraftPost(params.slug)
    : await getPublishedPost(params.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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Toggling draft mode from Sanity Studio

I add a "Preview" button in the document action bar. In sanity.config.ts:

// sanity.config.ts
import { defineConfig } from 'sanity';
import { structureTool } from 'sanity/structure';
import { visionTool } from '@sanity/vision';
import schemas from './schemas';

export default defineConfig({
  projectId: 'abc123',
  dataset: 'production',
  plugins: [
    structureTool(),
    visionTool(),
  ],
  schema: { types: schemas },
  document: {
    actions: (prev, { schemaType }) => {
      if (schemaType === 'post') {
        return [
          ...prev,
          {
            label: 'Preview',
            onHandle: () => {
              const slug = /* extract slug from document */;
              const previewUrl = `${process.env.NEXT_PUBLIC_SITE_URL}/api/draft?secret=${process.env.SANITY_PREVIEW_SECRET}&slug=${slug}`;
              window.open(previewUrl, '_blank');
            },
          },
        ];
      }
      return prev;
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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 });
  }

  draftMode().enable();
  redirect(`/posts/${slug}`);
}
Enter fullscreen mode Exit fullscreen mode

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 });

  revalidateTag(`post:${slug}`);
  return new Response('Revalidated', { status: 200 });
}
Enter fullscreen mode Exit fullscreen mode

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. I've had queries take 800ms because we expanded four levels of references. 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 has saved me from re-architecting every time a client asks for live preview.

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);
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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 — I store it in an environment variable at the edge so rotation is a config change, not a redeploy.

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);
});
Enter fullscreen mode Exit fullscreen mode

Two requests, runs in seconds, and it has caught a real regression — a refactor that accidentally made sanityFetch default to the preview client. The cost of the test is nothing next to the cost of that reaching production.

Top comments (0)