Why Your Next.js App Router Page is Stale: Cache Tags, Revalidation, and CMS Publishing

In Brief
A stale App Router page needs a freshness contract before it needs more cache tags. Decide how quickly each route should reflect CMS changes, then check which fetches, cached work, route output, webhook tags, and preview paths depend on that content. revalidateTag only helps when the tagged work matches the dependency you are trying to refresh.
Stale content bugs are rarely as simple as "the cache is broken". That is the phrase people reach for when an editor publishes a change, a customer still sees the old page, and the team cannot explain which layer is holding on to which version.
In the App Router, that confusion is easier to create because rendering, data fetching, cache lifetime, route output, server components, mutations, and invalidation all sit closer together than they did in older Next.js patterns. That closeness is powerful when the model is understood. It is infuriating when nobody can say whether the stale thing is a fetch response, a rendered route, a CDN edge response, a CMS webhook, or a client‑side view that never asked for fresh data.
The fix starts by being precise. "Stale" is not a diagnosis. It is a symptom.
Name the Freshness Contract First
Before touching code, decide what the page actually promises.
A news homepage, a product price, a legal notice, and an evergreen article should not have the same freshness contract. Some content can be slightly stale if users get fast pages and the next request refreshes the data. Some content needs read‑your‑own‑writes behaviour, where the editor or user sees their change immediately. Some content only needs to update after a scheduled build because the business has accepted that publishing model.
Next.js gives teams several ways to express those trade‑offs. The current App Router documentation explains cache tags and revalidation as part of a wider caching model, and the important detail is that not every invalidation method has the same user‑facing behaviour.
If nobody has named the freshness contract, the team will keep arguing about implementation details. One person wants instant updates everywhere. Another wants maximum caching. Another wants fewer builds. Those are all reasonable goals in isolation, but they cannot all win at once.
Start with the route:
- How stale may this page be?
- Who notices if it is stale?
- Does a published CMS change need to appear immediately, on next visit, or on the next scheduled rebuild?
- Is the route static, dynamic, streamed, or partly cached?
- Which content types feed it?
That gives the debugging work a target.
Cache Tags are Not Magic Strings
Cache tags are useful because they let you connect cached work to the thing that changed. If a CMS entry updates, the webhook can invalidate a tag associated with that entry, content type, section, or route family.
They only help when the cached work has actually been tagged. In current App Router code that usually means adding next: { tags: [...] } to cached fetch calls, or using cacheTag inside a cached function or component with the use cache directive. A tag name in a webhook handler does nothing unless the page's data path used the same tag when the cache entry was created.
The mistake is treating tags as decorative labels. A tag should represent an invalidation boundary the team actually understands.
For example, article:my-slug is useful if the page only depends on that article. articles may be useful for listing pages. navigation may be useful if the article title appears in a menu. homepage may be useful if a featured article block needs to refresh. Those tags describe different dependencies, so they should not all be collapsed into one vague content tag unless the site is small enough for that bluntness to be harmless.
The newer revalidateTag behaviour matters here. The Next.js API reference for `revalidateTag` recommends using the profile="max" pattern for stale‑whilst‑revalidate behaviour. That means the cached entry is marked stale, and fresh data is fetched when a page using that tag is visited again. That is excellent for many content pages. It is not the same as making every currently open browser tab change instantly.
For user mutations where read‑your‑own‑writes matters, the current API also has `updateTag`, but that has a narrower job: it is for Server Actions, not webhook Route Handlers. A CMS publish webhook will usually use revalidateTag or revalidatePath; a user saving something through the application may need updateTag so the next read does not serve the old value back to the same flow.
That distinction often explains the bug report. The webhook fired. The tag was invalidated. The next visitor may still see stale content whilst the new version is being prepared. Depending on the route, the first request after invalidation may be part of the refresh story rather than proof that invalidation failed.
Revalidation Has to Follow the Dependency Graph
Most real pages depend on more than one CMS entry.
An article page might depend on the article, the author, related articles, category labels, global navigation, footer links, image metadata, and a services panel. A category page might depend on every article in that category. A homepage might depend on a curated selection, the newest articles, and shared page data.
If the webhook only invalidates the edited entry, the directly affected page may update whilst the surrounding surfaces stay stale. That feels random to editors because they do not think in route dependency graphs. They think "I changed the title, why is the old title still on the homepage?"
The technical fix is not to revalidate everything every time. That gives up the benefits of caching and can create avoidable load. The fix is to map content changes to route dependencies deliberately.
For a CMS‑driven site, I like to document this as a small table:
- Article publish updates the article route, article listing pages, category listing pages, homepage modules, sitemap outputs, and related article surfaces.
- Category rename updates category routes, article pages that display category labels, breadcrumbs, and schema.
- Global navigation updates every route that renders it, or a shared navigation cache tag.
- Image metadata updates pages using that image and any social image generation path.
That table is not glamorous, but it stops revalidation being folklore.
CMS Webhooks Need to Be Boring
A CMS webhook should be reliable, idempotent, observable, and small enough to reason about. Contentful describes webhooks as HTTP callbacks that notify external systems when data changes, and its documentation also notes retry behaviour, timeouts, filtering, and idempotency. Those details matter once webhooks become part of publishing.
The webhook handler should not try to rebuild the world synchronously. It should verify the request, identify the event, map that event to invalidation work, respond quickly, and leave enough logs to explain what happened later.
The most useful logs are not noisy. They answer the questions people ask during an incident:
- Which CMS event arrived?
- Which entry or asset changed?
- Which tags or paths were invalidated?
- Did the handler treat the event as duplicate?
- Did revalidation succeed, fail, or get skipped?
- Which deployment or environment received the request?
Without that, teams debug stale content by clicking around the site and guessing.
Preview Freshness is Not Published Freshness
Preview and production are often confused because both involve "seeing the latest content". They are different promises.
Preview should help editors see draft content before publication. Published freshness should control what public users and crawlers see after content is released. A route can have excellent preview behaviour and weak production revalidation, or the other way around.
That separation is especially important when Contentful, Draft Mode, static pages, and revalidation are all involved. The preview path may fetch draft data dynamically. The public path may use cached published data. Fixing one does not automatically fix the other.
If an editor says "the page is stale", ask whether they mean preview or public. Then test the exact path they used. A copied preview URL, an iframe inside the CMS, a production URL in an incognito window, and a page opened from a deployment preview can all exercise different boundaries.
A Debugging Pass That Usually Works
I usually start with one controlled content change.
Pick a harmless field that appears in a visible place. Change it in the CMS. Publish it. Then trace it through the system without changing five other things at once.
Check the CMS event first. Did the publish event fire? Did the webhook receive it in the right environment? Did the handler recognise the content type? Did it map the change to the tags or paths you expected?
Then check the route. Does the server fetch fresh data when forced to? Is the route using the cache you think it is using? Does the stale version appear only on listing pages, only on detail pages, or everywhere? Are there separate caches for API responses, route output, CDN responses, and client‑side state?
Finally check the user journey. If the system uses stale‑whilst‑revalidate, does the second request show the new version? If the page remains stale, which dependency still points at the old content? If only one part of the page updates, which shared component or related query is on a different invalidation path?
You are looking for the first place where the system's story stops matching the page in front of you.
When to Simplify
The best cache model is not the one with the cleverest invalidation strategy. It is the one the team can operate under pressure.
If every content type has bespoke tag logic, nobody trusts it. If every publish event invalidates the whole site, performance and deployment cost suffer. If the CMS owns freshness but the front end hides dependencies inside scattered fetch calls, editors will see inconsistent behaviour.
Good systems keep the number of cache boundaries small enough to understand. They name tags consistently. They log invalidation decisions. They treat content freshness as part of the publishing workflow, not as an accidental side effect of rendering.
That is also why this topic belongs near Incremental Static Regeneration and data‑fetching cache strategy. The implementation details have changed over time, but the underlying question has not: how fresh does this route need to be, and what cost are we willing to pay for that freshness?
Wrapping Up
Stale App Router pages are not solved by shouting at the cache. They are solved by understanding the route's freshness rules, naming the dependencies, choosing the right invalidation behaviour, and making the CMS publishing path observable.
Cache tags are useful when they express real ownership. Revalidation is useful when it follows the page dependency graph. Webhooks are useful when they are boring enough to trust.
The work is not glamorous, but neither is explaining to an editor why the old headline is still live.
Key Takeaways
- Stale content is a symptom, not a diagnosis.
- Cache tags should describe real invalidation boundaries, not vague content buckets.
revalidateTagcan use stale‑whilst‑revalidate behaviour, which is not the same as instant visible freshness.- CMS publishing needs a documented route dependency map.
- Preview freshness and published freshness should be debugged separately.