Skip to content

Draft mode is on, and the page still shows published content

Draft mode only bypasses caches for reads that know about it. One direct client.fetch, or a token-less client, and the editor sees yesterday's copy.

Sanity blog3 min readships at docs/solutions/blog-sanity/draft-mode-and-the-app-router-cache.md

Tags: sanity · draft-mode · caching · app-router · preview

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 fetch cache and goes to the network;
  • re-executes 'use cache' scopes and unstable_cache instead 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:

  1. Caching: handled by the cookie.
  2. 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:

  1. Visit /blog/<slug> in a normal window. Note the text.
  2. Visit the enable URL with the correct secret. You should land on the post, see the banner, and see the draft text.
  3. Edit the draft in the Studio without publishing, reload the preview. The new text should appear.
  4. 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.