• 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]
18 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. The route cache answers from inside the app and stamps 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

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

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

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. 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.
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. 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.
Rather than bypass the Sanity CDN, the Astro edition makes it unable to answer with a copy older than the last publish. Every published read carries a content version as an extra GROQ parameter, $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 page
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. With the store inside the app that is a lookup you skip, decided before the middleware runs. Basic Auth is the case that makes owning the store worth it. Shared caches refuse to store any response to a request carrying an Authorization 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.
The rule about caches the webhook cannot reach caught the Astro edition once already. Its sitemap, 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

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 unable to hand back anything older than the last publish, bypassed or versioned, 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 Ordo is this, already decided in both the Next.js and Astro editions: the Sanity CDN bypassed in production on Next.js and versioned per publish on Astro, 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?

It depends on what your second cache does on a deploy. If it persists across deployments, as the Next.js Data Cache does on Vercel, bypass the CDN: live reads only happen for the pages a publish just invalidated, so the cost is a handful of calls a day and freshness is guaranteed. If it starts empty on every deploy, as Astro's build-keyed route cache does, keep the CDN on and key every published read to a version the publish webhook bumps, so the render after a publish is a guaranteed CDN miss and a release renders from the warm CDN instead of the live API. What you should never do is leave the CDN on with neither, because a webhook that fires before the edge has caught up re-caches the old value. In development use the CDN either way, 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 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?

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

BlogBlogRoadmapRoadmapGet accessGet accessPrivacy PolicyPrivacy PolicyTerms Of ServiceTerms Of ServiceImprintImprint

© Ordo

Built by edoardolunardi.dev
2026