Next.js Cache Tag Invalidation: Stop Ghost Data Fast
Next.js Cache Tag Invalidation: Stop Ghost Data Fast
Multi-collection cache tagging, typed cache policies, and CDN-safe tactics to prevent stale data in Next.js
·Updated on:··
⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
Tagging a cached query with only its primary entity is the reason renamed categories, edited product names, and corrected citations keep showing up as stale data long after an editor publishes the fix. The moment a cached loader joins data from more than one collection, that loader has more than one upstream dependency, and every one of those dependencies needs its own cache tag. This guide walks through building a dependency matrix for your data loaders, a policy helper that enforces multi-tag registration at the type level, and the CDN header limits that determine how far you can push this before a tag list gets truncated.
The Ghost Category
I hit this on a Payload CMS build where an editor renamed a blog category from "Nutrition & Gut" to "Gut Health" and updated the slug to match. The dedicated category page picked up the new title within seconds. The main blog listing, the category filter dropdowns, and every related-article widget across the site kept showing "Nutrition & Gut" for days. Clicking the old category link from any of those stale widgets landed on a 404, because the old slug no longer existed in the database.
The mutation hook had fired. The purge log showed the tag going out. Redis and the CDN both acknowledged it:
The archive page that was still serving the old title had never subscribed to that tag in the first place.
Why the 1 Entity = 1 Tag Model Falls Apart
Most caching tutorials teach a mental model where each collection gets exactly one tag:
text
Blog post -> tag: "blogs"
Product -> tag: "products"
Category -> tag: "categories"
That model holds for a loader that reads from a single table. It breaks the moment a loader joins across collections, which is most of them. Consider getBlogCategoryArchive(slug): it reads the category title and description from blog-categories, queries blogs for the latest posts in that category, and resolves nested author and media relationships. Tagging that query with only its primary return type misses two of its three data sources:
ts
// File: src/data/content/blog-archive.ts// Only tagged with "blogs" — the category dependency is invisible to the purge systemasyncfunctiongetCachedBlogCategoryArchive(siteSlug: string, locale: string, categorySlug: string) {
"use cache: remote";
cacheTag();
(siteSlug, locale, categorySlug);
}
When the category webhook purges blog-categories, this cache entry has no subscription to that tag and stays untouched. It keeps serving the old title until an author happens to publish a new post in that category, which is the only event that touches the blogs tag it actually registered.
The Dependency Graph Model
The fix starts with a change in how you think about what a cached query represents. A cached query result is a materialized view over every collection it reads from, joins against, or embeds — not a copy of one row from one table. The cache tags on that entry need to cover every node in that dependency graph, not just the one the return type is named after.
Working this out before writing the loader makes the dependencies explicit:
Building the Policy Helper
Step 1: Define a Closed Set of Cacheable Collections
A shared, typed list keeps loaders from registering a mistyped or invented tag that silently never matches a purge event.
The collections parameter has no default and takes an array, so a loader that joins two sources has to name both. There's no path through this function that lets a loader register only its primary tag by omission.
Step 3: Write the Loader Against the Full Dependency List
A mutation to either blogs or blog-categories now invalidates this entry, because the entry is subscribed to both tags rather than one. For the full mechanics of what revalidateTag and updateTag each guarantee once a purge fires, the revalidateTag vs updateTag cache strategy guide covers the difference between a background refresh and an immediate one, which matters once the tag itself is correct.
The Embedded Relationship Edge Case
Rich text fields hide a version of this same gap. A blog post body that contains inline citation markers gets rendered through a converter that resolves citation IDs into reference cards at render time:
Correcting a typo in a scientific reference title updates the scientific-references collection. If the blog post loader only tagged itself with blogs, the article keeps showing the old reference title until the post itself gets republished, since nothing else touches its cache entry. The loader needs the embedded collection in its policy the same way the archive needed the category collection:
Any loader that resolves an embedded relationship at render time — products referenced from a recipe, authors referenced from a post, media referenced from a block — needs the same treatment: trace what it reads, and tag all of it.
Two Invalidation Policies, Not One
The webhook side of this needs its own split. Versioned content and taxonomy collections don't invalidate on the same trigger.
Collapsing both into a single "purge on every save" hook produces the opposite failure: authors autosaving a draft blog post trigger public cache purges dozens of times an hour, which defeats the point of caching the page at all. For the mechanics of writing a Payload CMS hook that reads document status safely inside a transaction, the guide to safe data manipulation in Payload CMS hooks covers the guard-flag pattern this split depends on.
CDN Header Limits by Provider
The tag list on a response header is bounded by the CDN in front of it, and each provider enforces the limit differently.
Two practices keep any of these limits from becoming a problem. First, never emit an unbounded number of per-document tags on a listing page — tag the collection itself, not each of the 500 items rendered on it. Second, keep tag namespaces short: s:acme:l:en:c:blogs carries the same information as a longer, fully-spelled-out version and takes a fraction of the header budget.
The Three Rules
Tag the inputs to a query, not just its output — every collection a loader's join or filter touches needs a tag registered on that cache entry. Require an explicit array of dependency collections in your policy helper rather than a single default, so a loader can't register just its primary tag by omission. Split invalidation into a published-only policy for versioned content and an always policy for taxonomies, since a taxonomy has no draft state to wait for.
FAQ
Why didn't revalidateTag("blog-categories") clear the stale archive page?
Because the archive loader was only tagged with blogs. revalidateTag only invalidates cache entries that registered the exact tag you pass it — it has no way to know the archive query also depends on the categories collection unless that dependency was declared at cache-write time.
Does this apply outside of Next.js Cache Components?
Yes. The dependency-matrix approach applies to any tag-based caching system — Redis with a tag index, a CDN surrogate-key setup, or a custom invalidation layer. The specific API differs; the requirement that every upstream collection get its own tag does not.
How do I find all the dependencies for an existing loader?
Read the query it executes and list every collection or table referenced in a join, a WHERE ... IN, or a nested relationship resolve. If the loader calls another function to fetch related data, that function's dependencies belong on the list too.
What if two loaders have very different dependency lists for the same collection?
That's expected and fine. A blog detail page depends on scientific-references because it renders citations; a blog listing page usually doesn't reference citations at all and shouldn't carry that tag. Matching each loader's tags to what it actually reads keeps invalidations from firing more broadly than necessary.
Should draft content ever trigger these purges?
No. Draft-only saves should route through the published-only policy and skip the purge entirely. Purging a public cache tag on every autosave invalidates pages that visitors are actively being served from, for a version of the content nobody outside the CMS can see yet.
Wrapping Up
A cached query that joins more than one collection has more than one dependency, and tagging it with only its primary return type is what produces ghost data after a perfectly successful CMS mutation. Building a dependency matrix before writing the loader, enforcing the full collection list through a typed policy helper, and splitting invalidation into published-only and always policies closes that gap without giving up the caching layer that makes the site fast in the first place.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks,
Matija
`site:${siteSlug}:locale:${locale}:blogs`
return
queryCategoryArchive
Loader
Returns
Required tags
Why
Blog detail
BlogView
blogs, scientific-references
Renders embedded citation cards resolved from a separate collection
Blog category archive
BlogArchiveView
blogs, blog-categories
Output changes on a new post or a category rename
Recipe by lifestyle
RecipeArchiveView
recipes, dietary-attributes
Output changes on a recipe edit or a lifestyle badge update
Product category archive
ProductArchiveView
products, product-categories
Output changes on a product edit or a taxonomy change