All articles

Stale Sanity Content in Next.js: The Two Caches and How to Fix Them

Sanity content not updating in Next.js, or only showing after a redeploy? Two caches cause it: the Sanity CDN and the Next.js Data Cache. How each one works and how to fix stale content.

[Author]
Edoardo Lunardi
[Published]
[Reading time]
A client emails you: the price changed in the CMS three hours ago, the site still shows the old one. You open Sanity, the field is correct. You redeploy, the new price appears, and you file it under build flake. The redeploy fixed nothing. Your Sanity content was not updating in Next.js because it sat behind two caches between the published document and the page a visitor loads, and clearing them by accident is no fix. I have watched changes fail to reflect on the live site across Next.js and Sanity projects for years, and the cause is the same every time. Stale content is a caching problem with two layers, and most engineers only know about one.

Two caches, not one

Between a published document in Sanity and the HTML a browser receives, your content passes through two independent caches. The Sanity CDN caches API responses at the edge. The Next.js Data Cache stores the result of every server fetch. They answer to different controls, expire on different triggers, and neither one knows the other exists. A page serves stale content when either cache holds an old copy, so you can fix one and still ship the wrong price because the other is lying.
  • The Sanity CDN, controlled by the useCdn flag on your client. With it on, Sanity serves a cached response from the edge: fast, and a few seconds to a few minutes behind the latest publish.
  • The Next.js Data Cache, controlled by the revalidate and tags options you pass to a fetch. It persists across requests and, on Vercel, survives redeploys.
Once you know there are two, debugging stops being guesswork. You ask which cache is stale, not whether the code is broken.

Find which cache is serving the old copy

Before changing any code, make Next.js tell you what it is doing. Set logging.fetches.fullUrl to true in next.config.ts and every server fetch prints to the terminal with a cache HIT or MISS and the tags applied. A HIT where you expected fresh data means the Next.js Data Cache is holding the old copy and your revalidation never ran. A MISS that still returns the old value means the request read Sanity through the CDN and got a stale edge copy, the case bypassing the CDN in production removes. One line of config turns a guessing game into a reading.
When the terminal shows fresh data but the browser shows stale, the webhook is the suspect. A publish should fire a POST at your revalidation route within a second or two. If nothing arrives, the GROQ-powered webhook is misconfigured or filtered too tightly, and no cache tuning helps until it fires.

The Sanity CDN serves the first stale copy

Set useCdn: true and Sanity returns content from its edge cache. For a marketing page that updates a few times a week, that delay is invisible and the bandwidth savings are real. The trouble starts when you wire Next.js revalidation on top of it. Next.js revalidates, calls Sanity for fresh data, and Sanity hands back the same cached response it had a moment ago. Your revalidation worked. It refilled the Next.js cache with stale data from the CDN.
The fix in production is to bypass the CDN. Set useCdn: false so a production read hits Sanity's live API and returns the current content, with no edge copy to fall behind. The cost worry, that the live API allowance is smaller and pricier than the CDN's, does not bite: in production the Next.js Data Cache fronts every fetch, with force-cache and tags, so a page serves from cache until a webhook busts its tag. The live API call fires only on the first request after an invalidation, so a busy site makes a handful of Sanity calls a day, not one per visitor. You touch the API only when content actually changed.
Development flips the trade. There the CDN is the cheaper read, because local iteration would otherwise spend live API calls on every reload, so useCdn goes true and a few seconds of edge lag costs nothing while you build. Bypassing the CDN in production also closes a gap that catches teams who leave it on: a webhook fires before the edge finishes propagating, so a revalidation that reads the CDN re-caches the old value. parseBody from next-sanity takes a delay argument to cover that window, but with the CDN out of the production path the window never opens.

Time-based revalidation is a guess

revalidate: 60 tells Next.js to serve a cached page for up to sixty seconds before regenerating it. For content that changes on a schedule, that is fine. For a typo fix an editor wants live now, it means up to sixty seconds of the wrong text on a page someone is reading. Shorten the window and you trade freshness for load on Sanity. Set revalidate: 0 and you remove caching entirely, which solves staleness by giving up the performance you came to Next.js for.
Two values get confused here. revalidate: 0 means revalidate on every request, so nothing is cached. revalidate: false means cache until something invalidates it by hand. They read as similar and behave as opposites. Pick false when you intend to control freshness yourself, which is where tags come in.

Tag-based revalidation is the production answer

Time-based revalidation expires a page on a clock. Tag-based revalidation expires it on an event, and the event is an editor pressing publish. You attach tags to a fetch, then bust those exact tags when matching content changes. Nothing else regenerates. A homepage tagged homepage and a post tagged post:${slug} revalidate independently, each one only when its own document moves.
The wiring has three parts. Your sanityFetch helper passes tags and, when tags are present, sets revalidate: false so the two strategies never fight. A route handler at /api/revalidate in the App Router validates the request and calls revalidateTag for whatever changed. A GROQ-powered webhook in Sanity calls that route on create, update, and delete, with a filter narrow enough that you are not revalidating the world on every keystroke.
// Invalidation flow
//
// Publish -> GROQ webhook -> POST /api/revalidate -> revalidateTag(tag)
//   -> Next.js Data Cache drops the tag -> next request MISS
//   -> Sanity live API (useCdn: false) -> fresh page
This is more setup than a single number, and it is the difference between a site that updates the instant an editor publishes and one that updates eventually. On-demand revalidation through tags is the pattern every production Sanity and Next.js site converges on.

The next piece, when it's worth sending

I write up the Next.js and Sanity problems that cost real time, the fetch layer and the parts nobody quotes for, and how I solve them in production. No schedule, no filler. Leave your email and I send you the next one.

Draft mode is the same problem inverted

Editors need the opposite of a cache. They want to see content that is not published yet. Draft mode handles it by switching the perspective of your fetch from published to drafts and forcing useCdn: false, so the Studio preview shows work in progress instead of the live page. It is the two-cache model again, read from the other side. Get the boundary right once and preview, live content, and revalidation all flow through the same sanityFetch, each picking the right cache for its job.

Why not let defineLive do all of this

Sanity ships defineLive, a fetch helper and a <SanityLive> component that handle caching, revalidation, and draft mode for you, with real-time updates as content changes. For most applications it is the recommended path, and it removes most of the wiring above. I still reach for the manual setup on production builds, for one current reason.
On Next.js 16, <SanityLive> interacts with the default link prefetch in a way that multiplies requests: a published change invalidates the client cache, prefetches fire again, tagged routes re-fetch and re-write, and your Sanity API and Vercel ISR bills climb with traffic. Sanity has documented this and for now recommends Next.js 15 with the older toolkit, or, on 16, driving revalidation from a Sanity Function instead of rendering <SanityLive> everywhere. Until it settles, a hand-built tag-based layer with the CDN bypassed in production is the cost I can predict.

The part nobody quotes for

The redeploy that fixed the price never fixed anything. It cleared two caches by accident and left the boundary between them undefined, which is why the bug came back. Owning that boundary, with the CDN off where freshness matters, the Data Cache busted by tags on publish, and draft mode reading drafts with the CDN bypassed, is what turns stale content from a recurring incident into a solved problem.
It is also the work that never makes it into an estimate and gets rebuilt on every new project. The fetch layer in The Content Architecture is this, already decided: useCdn handled per environment, tag-based revalidation wired to a webhook, draft mode and live preview connected, so a client build starts past the part that usually costs the first three days. For the reasoning behind the rest of the system, the companion piece on content architecture covers how the schema underneath it is modeled.

Common questions

Why is my Sanity content not updating in Next.js?

Two caches sit between your content and the page: the Sanity CDN and the Next.js Data Cache. An edit can be live in Sanity while one still serves the old copy. Bust the Next.js cache on publish with tag-based revalidation, and bypass the CDN in production so the refetch returns fresh content.

Why do my Sanity changes only show after a redeploy?

A redeploy clears the Next.js Data Cache as a side effect, so the new content appears and then goes stale again on the next edit. Wire a webhook to revalidate the affected tags the moment an editor publishes, and the redeploy stops being part of the loop.

Should useCdn be true or false in production?

False in production, true in development. In production you bypass the CDN so reads return the current content at once, and the Next.js Data Cache keeps API usage low by serving almost every request without calling Sanity. In development the CDN is the cheaper option, since local iteration would otherwise spend live API calls on every reload.

How do I revalidate a Next.js page when I publish in Sanity?

Tag your fetches, then bust those tags on publish. A GROQ-powered webhook calls a route handler that runs revalidateTag for the document type or slug that changed.

Is defineLive safe to use on Next.js 16?

It works, but on Next.js 16 the <SanityLive> component can multiply requests through link prefetch and raise your Sanity API and Vercel ISR bills. Sanity suggests staying on Next.js 15 with the older toolkit, or driving revalidation from a Sanity Function on 16. A manual tag-based layer with the CDN bypassed in production is the predictable option today.

Engineering notes from The Content Architecture

This is the thinking behind The Content Architecture, the Next.js and Sanity foundation I ship client work on. If you work on this stack, the list is where the next breakdown lands first. Low volume, high signal.