---
title: "Next.js Cache Tag Invalidation: Stop Ghost Data Fast"
slug: "nextjs-cache-tag-invalidation-multi-collection"
published: "2026-08-24"
updated: "2026-08-28"
validated: "2026-08-28"
categories:
  - "Next.js"
tags:
  - "Next.js cache tag invalidation"
  - "multi-collection cache tagging"
  - "cache dependency graph"
  - "typed cache policy"
  - "CDN header limits"
  - "Payload CMS"
  - "revalidateTag vs updateTag"
  - "surrogate-key"
  - "ghost data stale cache"
  - "cacheTag"
llm-intent: "reference"
audience-level: "advanced"
framework-versions:
  - "next.js"
  - "typescript"
  - "payload cms"
  - "redis"
  - "cloudflare"
status: "stable"
llm-purpose: "Next.js cache tag invalidation: tag every collection dependency, enforce a typed cache policy, and stop stale ghost data — implement these fixes today."
llm-prereqs:
  - "Access to Next.js"
  - "Access to TypeScript"
  - "Access to Payload CMS"
  - "Access to Redis"
  - "Access to Cloudflare"
llm-outputs:
  - "Completed outcome: Next.js cache tag invalidation: tag every collection dependency, enforce a typed cache policy, and stop stale ghost data — implement these fixes today."
---

**Summary Triples**
- (Next.js Cache Tag Invalidation: Stop Ghost Data Fast, focuses-on, Next.js cache tag invalidation: tag every collection dependency, enforce a typed cache policy, and stop stale ghost data — implement these fixes today.)
- (Next.js Cache Tag Invalidation: Stop Ghost Data Fast, category, general)

### {GOAL}
Next.js cache tag invalidation: tag every collection dependency, enforce a typed cache policy, and stop stale ghost data — implement these fixes today.

### {PREREQS}
- Access to Next.js
- Access to TypeScript
- Access to Payload CMS
- Access to Redis
- Access to Cloudflare

### {STEPS}
1. Audit loader dependencies
2. Define cacheable collections list
3. Implement tag helper functions
4. Build typed cache-policy helper
5. Update loaders to register dependencies
6. Split webhook invalidation policies

<!-- llm:goal="Next.js cache tag invalidation: tag every collection dependency, enforce a typed cache policy, and stop stale ghost data — implement these fixes today." -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to Redis" -->
<!-- llm:prereq="Access to Cloudflare" -->
<!-- llm:output="Completed outcome: Next.js cache tag invalidation: tag every collection dependency, enforce a typed cache policy, and stop stale ghost data — implement these fixes today." -->

# Next.js Cache Tag Invalidation: Stop Ghost Data Fast
> Next.js cache tag invalidation: tag every collection dependency, enforce a typed cache policy, and stop stale ghost data — implement these fixes today.
Matija Žiberna · 2026-08-24

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:

<table>
<tr><th>Loader</th><th>Returns</th><th>Required tags</th><th>Why</th></tr>
<tr><td>Blog detail</td><td><code>BlogView</code></td><td><code>blogs</code>, <code>scientific-references</code></td><td>Renders embedded citation cards resolved from a separate collection</td></tr>
<tr><td>Blog category archive</td><td><code>BlogArchiveView</code></td><td><code>blogs</code>, <code>blog-categories</code></td><td>Output changes on a new post or a category rename</td></tr>
<tr><td>Recipe by lifestyle</td><td><code>RecipeArchiveView</code></td><td><code>recipes</code>, <code>dietary-attributes</code></td><td>Output changes on a recipe edit or a lifestyle badge update</td></tr>
<tr><td>Product category archive</td><td><code>ProductArchiveView</code></td><td><code>products</code>, <code>product-categories</code></td><td>Output changes on a product edit or a taxonomy change</td></tr>
</table>

## 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 <a href="https://www.buildwithmatija.com/blog/nextjs-revalidate-tag-vs-update-tag-cache-invalidation">revalidateTag vs updateTag cache strategy guide</a> 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.

<table>
<tr><th>Policy</th><th>Applies to</th><th>Fires on</th></tr>
<tr><td>Published-only</td><td>Versioned collections: <code>blogs</code>, <code>recipes</code>, <code>products</code></td><td>Only when a document transitions to or from published status — a draft autosave should never purge a public cache</td></tr>
<tr><td>Always</td><td>Taxonomies: <code>blog-categories</code>, <code>product-categories</code>, <code>dietary-attributes</code></td><td>Every create, update, rename, or delete, since these have no draft state of their own and any change is already live</td></tr>
</table>

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 <a href="https://www.buildwithmatija.com/blog/payload-cms-hooks-safe-data-manipulation-postgresql">guide to safe data manipulation in Payload CMS hooks</a> 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.

<table>
<tr><th>CDN</th><th>Limit</th><th>Practical effect</th></tr>
<tr><td>Cloudflare</td><td>Cache-Tag header capped at 16 KB total; roughly 1,000 unique tags depending on tag length</td><td>Generous enough for collection-level tags; still fails if you tag a listing page with an ID for every item on it</td></tr>
<tr><td>Fastly</td><td>Surrogate-Key header capped at 16 KB total, with each individual key capped at 1 KB</td><td>Same shape of limit as Cloudflare — collection and tenant tags stay well under it, per-document tags on large listings do not</td></tr>
<tr><td>Akamai</td><td>Maximum of 128 tags per cached object, with a default response header size of 8 KB</td><td>The tightest of the three; a listing page with more than a handful of dependency tags plus per-document tags can hit this quickly</td></tr>
</table>

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

## LLM Response Snippet
```json
{
  "goal": "Next.js cache tag invalidation: tag every collection dependency, enforce a typed cache policy, and stop stale ghost data — implement these fixes today.",
  "responses": [
    {
      "question": "What does the article \"Next.js Cache Tag Invalidation: Stop Ghost Data Fast\" cover?",
      "answer": "Next.js cache tag invalidation: tag every collection dependency, enforce a typed cache policy, and stop stale ghost data — implement these fixes today."
    }
  ]
}
```