# 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.

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

```ts
// 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:

```ts
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:

```bash
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`:

```bash
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
