• FeaturesFeatures
  • The repoThe repo
  • ShowcaseShowcase
  • PricingPricing
  • FAQFAQ
  • BlogBlog
←All articlesAll articles

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

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

[Author]
Edoardo Lunardi
[Published]
27 Jun 2026
[Reading time]
14 min
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 the same failure across Next.js and Astro projects for years, and the cause never changes. 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. The pairing is not specific to Next.js either. Swap the framework and the Sanity CDN stays exactly where it was while the second cache changes shape, which is what the Astro section below works through.

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.
On Astro the reading comes off the response instead of the terminal. In production Vercel reports x-vercel-cache: HIT | MISS | STALE, and the dev provider sends X-Astro-Cache. A HIT on a route you just published means the tag was never busted, which points at the webhook rather than the client.

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: 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 it in production also closes a gap that catches teams who leave it on, where a webhook fires before the edge finishes propagating and the revalidation re-caches the old value.

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. The value you actually want is revalidate: false, cache until something invalidates it by hand, 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
Sanity GROQ-powered webhooks settings showing an enabled Revalidate webhook posting to the site's /api/revalidate route
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. The publish half is framework-neutral, one webhook busting tags for the document that changed, and on-demand revalidation through tags is the pattern every production Sanity site converges on, whatever renders it.

Build notes from The Content Architecture

The Content Architecture is a Sanity starter, in Next.js and Astro editions, built on decisions like this one. The list covers the parts that take the longest to get right: the fetch layer, the agentic layer, the parts that never make it into an estimate. New breakdowns land here first. Low volume, high signal.

Astro caches responses, not fetches

The two-cache model is not a Next.js quirk. It is what happens whenever a framework caches between the CMS and the browser, so swapping Next.js for Astro leaves the Sanity CDN exactly where it was and changes only the second cache. Astro has no data cache wrapping every fetch. It caches the rendered response for a route through Astro.cache, backed by a provider configured in astro.config.mjs: Vercel's CDN in production, an in-memory LRU in development.
The difference shows up when you go looking for the stale copy. In Next.js you tag a fetch, so one page can hold a fresh call next to a stale one. In Astro you tag the response, so a route is a hit or a miss as a whole. Astro.cache.set takes the tags for the page it just rendered: the document's _type, a doc:<id> tag, and the path it is routed at. Because the header and footer come from site-wide singletons that render on every page, every response carries those types too, so publishing one of them invalidates the site through a single tag. The publish half is unchanged from above: the same webhook, busting the same tags, with useCdn false in production.
What Astro adds is the bypass. Because it stores whole responses, any request that must not receive cached HTML has to miss on purpose: an editor in draft mode carrying the preview cookie, an agent negotiating for Markdown, a request behind Basic Auth. In memory that is a lookup you skip. On the CDN the lookup happens at the edge before your function runs, so it becomes a header contract instead: Vary: Cookie, Authorization, Accept gives those requests their own cache key, and anything rendered for one of them is marked private, no-store so it is never stored. Miss that and you have a fresh staleness bug with nothing to do with Sanity, where an editor sees a cached published page instead of their own draft.

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

On Next.js, and only there, 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 and the framework cache busted by tags on publish, 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 in both the Next.js and Astro editions: 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 framework 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 framework cache, the Next.js Data Cache or Astro's route 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 page when I publish in Sanity?

Tag your fetches, or your responses on Astro, then bust those tags on publish. A GROQ-powered webhook calls an endpoint that runs revalidateTag on Next.js, or cache.invalidate({ tags }) on Astro, for the document 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 Next.js 15 with the older toolkit, or driving revalidation from a Sanity Function on 16.

Does Astro have the same stale content problem?

Yes, with a different second cache. Astro caches the rendered response for a route through Astro.cache, on Vercel's CDN in production, instead of caching each fetch. The fix is the same: bust the route's tags on publish and keep useCdn: false in production.

For engineers building on the stack

The Content Architecture is the Sanity foundation, in Next.js and Astro editions, with the fetch layer already decided. If you build on this stack, the list is where new patterns, deep dives, and product updates land first. No filler, just the engineering.

BlogBlogRoadmapRoadmapGet accessGet accessPrivacy PolicyPrivacy PolicyTerms Of ServiceTerms Of Service

© The Content Architecture

Built by edoardolunardi.dev
2026