Preview drafts belong on the request, not the shared client
Draft Mode in Next.js looks like a toggle: enable a cookie, fetch drafts, done. The failure we keep seeing is one level lower — a shared Sanity client module that always uses perspective: 'previewDrafts' or always disables the CDN. That choice belongs on the request that is actually previewing, not on the import every page shares.
Two audiences, one import path
On a Sanity-backed Next.js site, almost every route eventually imports the same client helper. Public pages want published documents, CDN caching, and no token in the hot path. An editor in Draft Mode wants drafts.*, a read token, and no CDN. Those are opposite contracts.
If the shared helper hard-codes previewDrafts, public visitors can see unfinished copy whenever the GROQ filter is loose. If it hard-codes useCdn: false and a token for everyone, you have not leaked drafts — you have turned every page view into an authenticated API hit and thrown away ISR for the whole site.
The editor experience and the public experience cannot share one static client config. They can share one factory that receives the mode.
What Draft Mode actually changes
Next.js Draft Mode sets a bypass cookie and tells fetch to skip the Data Cache for that request. It does not tell Sanity anything. Sanity only serves drafts when you query the API with a token and a perspective that includes them — previewDrafts or raw — and with useCdn: false, because the CDN never holds draft documents.
So the correct branch is boring: read draftMode().isEnabled on the server, then build the client for that request. Published path: perspective published, CDN on, no token required for public content. Preview path: perspective previewDrafts, CDN off, read token present. Anything else is mixing the two.
We also keep the token out of any module that could be imported into a Client Component. Preview is a server concern. If the token can reach the browser bundle, rotating a leaked preview secret later will not be enough.
Where this shows up on our own site
Our notes use publishedAt as a schedule gate: future dates stay out of every public GROQ query. That is a separate concern from drafts. A scheduled note is finished and dated. A draft is unfinished and should never appear in Search Console.
Preview still has to respect both. An editor opening Draft Mode should see the draft overlay of a document, including work that is not ready to schedule. Public listings, sitemaps, and generateStaticParams must keep using the published perspective and the publishedAt <= now() filter. Collapsing those into one client is how a half-written note becomes a public URL.
The same split applies on client sites like Provale Cup, where editors own long clinical pages in Studio. Preview is how they check a page before publish. It is not how anonymous traffic should ever fetch.
The preview route is not decoration
The /api/preview handler has a short job: validate a secret, enable Draft Mode, redirect to a relative slug. Run it on the Node runtime so the cookie actually sticks. Validate the slug as a path on your site — reject open redirects that start with // or a host. Strip the secret from the destination URL so a shared Slack link is not a permanent credential.
Add an exit route that disables Draft Mode. Editors will not clear cookies by hand. While the cookie is present, send X-Robots-Tag: noindex from middleware. A preview URL pasted into a channel and later crawled is a real support incident, not a theoretical one.
Test the whole loop with next build && next start. Hot reload with an existing bypass cookie will lie to you about whether the branch is correct.
Ship the split as the default
Treat a shared Sanity client with a fixed perspective as a bug, the same way you would treat a shared database handle pointed at a staging schema in production. The factory takes isDraftMode (or an explicit perspective) and returns the matching client. Call sites that forget to pass the flag should fail closed to published.
That keeps public TTFB on the edge cache and keeps unfinished content where it belongs: behind a cookie, a secret, and a token that never leaves the server. Draft Mode stays a request. The CMS client stops pretending one config can serve both audiences.