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]
- [Reading time]
Two caches, not one
- •The Sanity CDN, controlled by the
useCdnflag 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
revalidateandtagsoptions you pass to a fetch. It persists across requests and, on Vercel, survives redeploys.
Find which cache is serving the old copy
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.X-Astro-Cache: HIT | MISS on every cacheable response, in every environment. Nothing is stored in a CDN, so there is no x-vercel-cache to read. A HIT on a route you just published means the tag was never busted, which points at the webhook rather than the client. A MISS that still renders the old value means the read behind it came back stale, and the Sanity CDN becomes the suspect.The Sanity CDN serves the first stale copy
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.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.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. Bypassing is not the only way to close it; the Astro section below keeps the CDN on and closes the same gap from the other side.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
homepage and a post tagged post:${slug} revalidate independently, each one only when its own document moves.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
Build notes from Ordo
Ordo 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
Astro.cache, backed by a provider configured in astro.config.mjs. The provider answers every lookup from inside the app, out of a store it owns: an in-process LRU by default, or Vercel's Runtime Cache behind a flag, one cache shared by every function instance so a single webhook call reaches all of them. No response leaves with a header that would let a CDN hold a copy, because a copy in a cache the webhook cannot reach outlives every publish.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. Every entry is also keyed by the build that rendered it, so a deploy reads a keyspace nothing has written to yet: the shared store survives the release, the previous release's HTML does not. The publish half is unchanged from above, the same webhook busting the same tags. Where the Astro edition differs is the first cache, which it keeps on.$contentVersion, that no query uses. The API CDN keys on the full request, parameters included, so a value it has never seen is a miss by construction. The webhook bumps the version and then expires the tags, in that order: a render slotted between the two steps reads the new version, where the other order could store a CDN-stale page that nothing expires again. The render after a publish reads origin once, and every later render of any page reuses the CDN copy under the same version, including every page's first render after a deploy, when the route cache starts empty. Draft reads pin useCdn: false as before. None of it is Astro-specific: it is a parameter on a Sanity client, and it works wherever the second cache is busted on publish. The Next.js edition still bypasses the CDN in production: its Data Cache fronts every fetch and survives a deploy, so live reads are already rare and the version would buy little.// Invalidation flow, Astro edition
//
// Publish -> GROQ webhook -> POST /api/revalidate
// -> contentVersion.bump() new $contentVersion, a CDN miss by construction
// -> cache.invalidate({ tags }) the route cache drops the page
// -> next request MISS -> Sanity CDN (origin under the new version) -> fresh pageAuthorization header, so a password-protected staging site behind a CDN is uncached by definition. Inside the app, a request whose credentials match exactly reads and writes a separate gated: keyspace, and anything else carrying that header skips the cache and lets the middleware answer. Miss the bypass 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.llms.txt and openapi.json still sent an s-maxage header from before the route cache existed, so two caches were live at once: a publish expired the route cache entry immediately, while Vercel's CDN kept serving the old sitemap for up to an hour, and a day beyond that on stale-while-revalidate. The file crawlers read to discover new pages was the one that lagged. Dropping the header left the route cache answering it, invalidated on publish like every page. Any response you let a CDN hold is a third cache, and no tag reaches it.Draft mode is the same problem inverted
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
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.<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
Common questions
Why is my Sanity content not updating in Next.js?
Why do my Sanity changes only show after a redeploy?
Should useCdn be true or false in production?
How do I revalidate a page when I publish in Sanity?
revalidateTag on Next.js, or bumps the content version and runs cache.invalidate({ tags }) on Astro, for the document that changed.Is defineLive safe to use on Next.js 16?
<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?
Astro.cache, in a store the app owns, an in-process LRU or Vercel's Runtime Cache shared across instances, instead of caching each fetch. The fix is the same shape: bust the route's tags on publish, and make sure the refetch cannot read a CDN copy older than that publish. The Astro edition does that by versioning every published read rather than with useCdn: false, because its route cache is keyed per build and starts empty on every deploy, and the CDN is what keeps that first render off the live API.For engineers building on the stack
Ordo 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.