Cache Tags in Next.js: How We Stopped Nuking the Whole Site on Every Publish
revalidateTag looks simple until your CMS editor clicks Publish and half the site rebuilds. Here's how we tag, invalidate, and debug cache in a real App Router app.

A CMS editor fixes a typo on the About page. Ninety seconds later, our monitoring lights up: product pages are cold, search is slow, and the ISR cache looks like someone pulled the plug. The culprit wasn't the CMS — it was our cache tag strategy, or lack of one.
This is the piece I wish we'd read before shipping our first tagged cache in the App Router. It's about revalidateTag, how to name tags so they don't become landmines, and how to actually debug them when things go sideways.
Why tags, and why they bite
The App Router gives you two revalidation levers: time-based (revalidate: 60) and on-demand (revalidateTag, revalidatePath). Tags are the interesting one because they let you invalidate by meaning — "all product pages," "this one article," "the nav" — instead of by URL.
The trap is that tags are just strings. There's no type system, no ownership, no scope. If two teams both use "products", one editor's action clears the other team's cache. If your tag is too broad, a small edit invalidates a huge surface. Too narrow, and stale content leaks because you forgot to tag something.
In one project we audited, a single revalidateTag("content") call was wired to every publish event across three content types. It worked — every page was always fresh — but the origin was doing about 4x the work it needed to. That's the failure mode: correctness by carpet bombing.
A tag naming convention that survives contact with a CMS
After a few rewrites, we settled on a three-part convention: <entity>:<id> for specific records, and <entity>:all for list views. Sometimes a fourth segment for locale.
// lib/cache-tags.ts
export const tags = {
article: (id: string) => `article:${id}` as const,
articleList: (locale: string) => `article:list:${locale}` as const,
product: (sku: string) => `product:${sku}` as const,
productList: () => `product:all` as const,
nav: () => `nav:global` as const,
} as const;
export type CacheTag = ReturnType<(typeof tags)[keyof typeof tags]>;
A few rules we enforce in review:
- No raw strings in fetch calls. Always go through the
tagshelper. This gives you grep-ability and a single place to change the schema. - One entity, one file. The helper lives next to the data layer so nobody imports the wrong module.
- List tags are separate from item tags. Editing an article invalidates
article:123andarticle:list:en. Deleting it also invalidates the list. Publishing a draft only invalidates the list.
Here's what a data fetcher looks like with this in place:
// lib/data/articles.ts
import { tags } from "@/lib/cache-tags";
export async function getArticle(id: string, locale: string) {
const res = await fetch(`${process.env.CMS_URL}/articles/${id}`, {
next: { tags: [tags.article(id), tags.articleList(locale)] },
});
if (!res.ok) throw new Error(`Article ${id} failed: ${res.status}`);
return res.json();
}
export async function listArticles(locale: string) {
const res = await fetch(`${process.env.CMS_URL}/articles?locale=${locale}`, {
next: { tags: [tags.articleList(locale)] },
});
return res.json();
}
Notice getArticle carries both tags. When the article changes, only article:123 fires. When any article in that locale is added or removed, article:list:en fires and only the list pages rebuild.
The mistake we kept making
Our first version tagged individual items with the list tag too aggressively, so editing one article invalidated every article page in that locale. Reverse it: list pages get the list tag, item pages get the item tag, and item pages also subscribe to the list tag only if they render list-shaped data (e.g. a "related articles" sidebar).
Wiring the webhook without foot-guns
Most CMSes fire a webhook on publish. The naive handler calls revalidateTag for whatever the CMS sends. That's where the carpet bombing starts. Instead, translate the CMS event into your tag vocabulary explicitly.
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
import { tags } from "@/lib/cache-tags";
type CmsEvent =
| { type: "article.updated"; id: string; locale: string }
| { type: "article.published"; id: string; locale: string }
| { type: "article.deleted"; id: string; locale: string }
| { type: "nav.updated" };
export async function POST(req: NextRequest) {
const secret = req.headers.get("x-cms-secret");
if (secret !== process.env.CMS_WEBHOOK_SECRET) {
return NextResponse.json({ ok: false }, { status: 401 });
}
const event = (await req.json()) as CmsEvent;
switch (event.type) {
case "article.updated":
revalidateTag(tags.article(event.id));
break;
case "article.published":
case "article.deleted":
revalidateTag(tags.article(event.id));
revalidateTag(tags.articleList(event.locale));
break;
case "nav.updated":
revalidateTag(tags.nav());
break;
}
return NextResponse.json({ ok: true, event: event.type });
}
A couple of things worth calling out:
- The handler is dumb on purpose. No fetching, no lookups. Fast, idempotent, easy to replay.
- The event shape is a discriminated union. If the CMS adds a new event type, TypeScript forces you to decide what to invalidate.
- Secret in a header, not a query param. Query params end up in access logs.
If your CMS can't send structured events, write a thin adapter between the webhook and this route. Don't let the CMS's payload shape leak into your cache logic.
Server Actions and mutations
Webhooks handle content editors. Server Actions handle your own app's writes. Same principle: after the mutation succeeds, invalidate the specific tags you touched.
// app/(dashboard)/articles/actions.ts
"use server";
import { revalidateTag } from "next/cache";
import { tags } from "@/lib/cache-tags";
import { z } from "zod";
const UpdateSchema = z.object({
id: z.string().min(1),
locale: z.string().min(2),
title: z.string().min(1),
});
export async function updateArticle(input: unknown) {
const parsed = UpdateSchema.parse(input);
await db.article.update({ where: { id: parsed.id }, data: { title: parsed.title } });
revalidateTag(tags.article(parsed.id));
// No list tag — title changes don't affect the list page shape.
return { ok: true };
}
The question to ask on every mutation: which rendered surfaces will now show a different value? Only tag those. If you can't answer that question quickly, your tag schema is too vague.
Debugging: the part nobody writes about
Cache bugs are silent. A page renders, it just renders stale data. Two things have saved us hours:
1. A dev-only tag logger. Wrap fetch in your data layer and log which tags each request registered. When a page is stale, you can trace which tag should have invalidated it.
async function taggedFetch(url: string, tags: string[]) {
if (process.env.NODE_ENV !== "production") {
console.log(`[cache] ${url} -> tags=[${tags.join(",")}]`);
}
return fetch(url, { next: { tags } });
}
2. A /debug/cache route (auth-gated, non-prod) that lets you fire revalidateTag manually with a dropdown of known tags. When QA reports "the homepage is stale," you can bust it in ten seconds and confirm whether the tag wiring or the source data is the problem.
One more gotcha: the fetch cache and dynamic APIs
revalidateTag works on cached fetch responses. If your route reads cookies(), headers(), or searchParams, it becomes dynamic and skips the data cache entirely — meaning your tag is registered but never consulted, because there's nothing cached to invalidate. We've watched people burn an afternoon on this. If a page needs to be personalised, tags don't help; you need a different strategy (client-side data, or splitting the personalised bits into a separate component).
Where we'd start
If you're retrofitting an existing App Router app, do these three things in order. First, add a lib/cache-tags.ts and forbid raw tag strings in review — even before you change any invalidation logic, this alone will surface half your bugs. Second, replace your one big revalidateTag("content") call with a discriminated-union webhook handler. Third, add the dev-only tag logger so the next stale-cache bug takes minutes, not hours.
If you'd rather have someone else untangle the caching layer on a shipping product, that's the kind of work we do. Either way, treat cache tags like a public API of your data layer: name them deliberately, version them carefully, and never let them become a dumping ground.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

React Server Components and Third-Party Scripts: The Hydration Trap Nobody Warned Us About
Analytics, chat widgets, and A/B testing tools quietly break React Server Components in ways that don't show up until production. Here's the pattern we use to keep them from poisoning hydration and TTI.

Streaming Suspense Boundaries: Where to Draw Them Without Breaking LCP
Suspense boundaries in the Next.js App Router are a scalpel, not a sledgehammer. Put them in the wrong place and you'll ship a page that streams beautifully but tanks LCP. Here's how we decide where they go.

Route Handlers vs Server Actions for Public APIs: Pick the Right Door
Server actions look tempting for every mutation, but they're the wrong front door for public APIs. Here's the rule we use in production, and the failure modes that taught us the difference.
