BuildWithMatija
  1. Home
  2. Blog
  3. Next.js
  4. 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

24th August 2026·Updated on:28th August 2026··
Next.js
Next.js Cache Tag Invalidation: Stop Ghost Data Fast

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

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • The Ghost Category
  • Why the 1 Entity = 1 Tag Model Falls Apart
  • The Dependency Graph Model
  • Building the Policy Helper
  • Step 1: Define a Closed Set of Cacheable Collections
  • Step 2: Require the Full Collection List at the Call Site
  • Step 3: Write the Loader Against the Full Dependency List
  • The Embedded Relationship Edge Case
  • Two Invalidation Policies, Not One
  • CDN Header Limits by Provider
  • The Three Rules
  • FAQ
  • Wrapping Up
On this page:
  • The Ghost Category
  • Why the 1 Entity = 1 Tag Model Falls Apart
  • The Dependency Graph Model
  • Building the Policy Helper
  • The Embedded Relationship Edge Case
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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:

text
[CACHE PURGE] Revalidated tag: site:acme:locale:en-CA:blog-categories

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 system
async function getCachedBlogCategoryArchive(siteSlug: string, locale: string, categorySlug: string) {
  "use cache: remote";
  cacheTag(`site:${siteSlug}:locale:${locale}:blogs`);
  return queryCategoryArchive(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:

LoaderReturnsRequired tagsWhy
Blog detailBlogViewblogs, scientific-referencesRenders embedded citation cards resolved from a separate collection
Blog category archiveBlogArchiveViewblogs, blog-categoriesOutput changes on a new post or a category rename
Recipe by lifestyleRecipeArchiveViewrecipes, dietary-attributesOutput changes on a recipe edit or a lifestyle badge update
Product category archiveProductArchiveViewproducts, product-categoriesOutput changes on a product edit or a taxonomy change

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.

ts
// File: src/cache/tags.ts

export const CACHEABLE_COLLECTIONS = [
  "blogs",
  "recipes",
  "products",
  "blog-categories",
  "product-categories",
  "dietary-attributes",
  "scientific-references",
] as const;

export type CacheableCollection = (typeof CACHEABLE_COLLECTIONS)[number];

export function collectionCacheTag(
  siteSlug: string,
  locale: string,
  collection: CacheableCollection,
): string {
  return `site:${siteSlug}:locale:${locale}:${collection}`;
}

export function siteCacheTag(siteSlug: string): string {
  return `site:${siteSlug}`;
}

Step 2: Require the Full Collection List at the Call Site

ts
// File: src/data/cache-policy.ts

import { cacheLife, cacheTag } from "next/cache";
import { collectionCacheTag, siteCacheTag, type CacheableCollection } from "@/cache/tags";

export function applyPublishedContentCachePolicy(input: {
  siteSlug: string;
  locale: string;
  collections: readonly CacheableCollection[];
  documentId?: number;
}): void {
  cacheLife("days");

  const tags = [
    siteCacheTag(input.siteSlug),
    ...input.collections.map((col) => collectionCacheTag(input.siteSlug, input.locale, col)),
  ];

  if (input.documentId) {
    tags.push(`site:${input.siteSlug}:locale:${input.locale}:doc:${input.documentId}`);
  }

  cacheTag(...tags);
}

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

ts
// File: src/data/content/blog-archive.ts

export async function getBlogCategoryArchive(
  siteSlug: string,
  locale: string,
  categorySlug: string,
): Promise<BlogArchiveData | null> {
  "use cache: remote";

  applyPublishedContentCachePolicy({
    siteSlug,
    locale,
    collections: ["blogs", "blog-categories"],
  });

  return queryCategoryArchiveFromDatabase({ siteSlug, locale, categorySlug });
}

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:

tsx
// File: src/components/RichText/CitationRenderer.tsx

const citationIds = collectCitationIds(post.body);
const references = await getScientificReferencesByIds(citationIds);

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:

ts
// File: src/data/content/blog-post.ts

async function getCachedBlogPost(siteSlug: string, locale: string, slug: string) {
  "use cache: remote";

  applyPublishedContentCachePolicy({
    siteSlug,
    locale,
    collections: ["blogs", "scientific-references"],
  });

  return queryBlogPostWithReferences(siteSlug, locale, slug);
}

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.

PolicyApplies toFires on
Published-onlyVersioned collections: blogs, recipes, productsOnly when a document transitions to or from published status — a draft autosave should never purge a public cache
AlwaysTaxonomies: blog-categories, product-categories, dietary-attributesEvery create, update, rename, or delete, since these have no draft state of their own and any change is already live

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.

CDNLimitPractical effect
CloudflareCache-Tag header capped at 16 KB total; roughly 1,000 unique tags depending on tag lengthGenerous enough for collection-level tags; still fails if you tag a listing page with an ID for every item on it
FastlySurrogate-Key header capped at 16 KB total, with each individual key capped at 1 KBSame shape of limit as Cloudflare — collection and tenant tags stay well under it, per-document tags on large listings do not
AkamaiMaximum of 128 tags per cached object, with a default response header size of 8 KBThe tightest of the three; a listing page with more than a handful of dependency tags plus per-document tags can hit this quickly

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