An editor clicks Preview in the Studio. The banner appears, so draft mode is clearly on. The text on the page is the published version.
Nothing errors, nothing logs, and the editor concludes that their edit did not save.
What draft mode actually does
draftMode().enable() sets one cookie, __prerender_bypass. For requests
carrying it, Next.js:
- skips the
fetchcache and goes to the network; - re-executes
'use cache'scopes andunstable_cacheinstead of reading them; - excludes the page from the ISR response cache and serves it with
Cache-Control: private, no-cache, no-store.
That is the whole feature. It removes caching from the request. It does not know what a draft is, it cannot make your CMS return one, and it has no opinion about which client you fetch with.
Sanity, meanwhile, stores a draft as a separate document with the id
drafts.<id>. A query run with the published perspective (the default, and
the only thing an anonymous client can see) will never return it, cached or not.
So there are two independent switches, and draft mode only flips the first:
- Caching: handled by the cookie.
- Which documents are visible: handled by the client's perspective and token.
Flip only one and you get exactly the symptom above: an uncached read of the published document.
The fix: one read path that flips both
// sanity/lib/fetch.ts
export async function sanityFetch<T>({ query, params = {}, tags = [], revalidate = 3600 }) {
const { isEnabled: isDraft } = await draftMode();
if (isDraft) {
if (!hasReadToken()) {
throw new Error("Draft mode is on but SANITY_API_READ_TOKEN is missing");
}
return draftClient.fetch<T>(query, params, { next: { revalidate: 0 } });
}
return client.fetch<T>(query, params, { next: { revalidate, tags } });
}
with the two clients differing only in what they can see:
export const client = createClient({ projectId, dataset, apiVersion, useCdn: true, perspective: "published" });
export const draftClient = client.withConfig({
useCdn: false, // the CDN cannot serve drafts
perspective: "drafts",
token: process.env.SANITY_API_READ_TOKEN,
});
Three details that cause the remaining failures:
useCdn: false is not optional for drafts. Sanity's CDN only holds
published content. A token-carrying request to the CDN either fails or returns
the published document.
The token must be a Viewer token. Anything with write rights is more access than a frontend should ever hold, and the difference is invisible in behaviour, so it is worth checking when you create it, not later.
Throw when the token is missing. Falling back to the anonymous client "gracefully" produces exactly the bug this document is about, only now with a plausible-looking banner on top of it.
The other cause: a read that bypasses the wrapper
If sanityFetch is correct and the page is still published-only, something is
fetching directly:
grep -rn "client.fetch" src/ | grep -v "sanity/lib"
Every hit is a read that does not know about draft mode. There is exactly one
legitimate exception, and it is worth understanding: generateStaticParams runs
at build time, outside any request, where draftMode() throws. It uses the
anonymous client on purpose.
Prove it, don't assume it
Draft mode is stateful and per-browser, so test it in a real browser and check all four transitions:
- Visit
/blog/<slug>in a normal window. Note the text. - Visit the enable URL with the correct secret. You should land on the post, see the banner, and see the draft text.
- Edit the draft in the Studio without publishing, reload the preview. The new text should appear.
- Press Exit. The published text should come back and the banner should go.
Then check the header on a preview response: it must say private and
no-store:
curl -sI -H "Cookie: __prerender_bypass=<value>" https://<your-site>/blog/<slug> | grep -i cache-control
If you have a CDN or proxy in front of Next.js that rewrites or ignores that header, a draft can land in a shared cache and be served to the public. That is the one genuinely dangerous failure mode in this whole area: worth verifying with two different browsers on the first deploy.
While you are here: do not cache draft reads
revalidate: 0 on the draft branch is deliberate. Tagging and caching a draft
read means an editor's preview can be served to another editor from cache, and
means an unpublished string can outlive the draft it came from. Previews are
rare and low-traffic; pay the network hop.