Cache Tags in Next.js: How We Stopped Nuking the Entire CDN on Every Publish
A war story about how `revalidateTag` and a disciplined tagging scheme replaced our nightly full-cache purge — and the four gotchas we hit getting there in production.

Our content team used to ship a typo fix and watch the entire site go cold for ninety seconds. Every page. Every locale. Every product listing. The fix wasn't a bigger CDN or a smarter origin — it was learning to use Next.js cache tags the way they were actually designed.
This is the story of how we replaced a nightly purge --all cron and a panicky webhook with a disciplined tag taxonomy, plus the four gotchas that nearly made us roll the whole thing back.
The setup: why full purges felt like the only option
The site in question is a content-heavy marketing platform with roughly 40k pages — a mix of editorial articles, product detail pages, and localized landing pages across six locales. Content lives in a headless CMS. The frontend is Next.js App Router on Vercel, with the standard fetch-based data layer.
When we first launched, our revalidation strategy was embarrassingly blunt:
- Editors clicked "Publish" in the CMS.
- A webhook hit
/api/revalidateon our app. - That route called
revalidatePath('/', 'layout').
That one line invalidates the entire app's cache tree. It worked, in the sense that new content appeared. It also meant that fixing a typo on one blog post forced every page on the site to re-render on next request. LCP spiked, our origin saw a thundering herd, and our edge cache hit rate dropped to the floor for a couple of minutes after every publish.
We knew about revalidateTag. We just hadn't taken it seriously. Here's what changed when we did.
What cache tags actually are
In the App Router, every fetch call can carry a next.tags array. Those tags get attached to the response in Next's data cache. Later, calling revalidateTag('some-tag') invalidates every cached entry that carries that tag — and nothing else.
// app/lib/cms.ts
export async function getArticle(slug: string) {
const res = await fetch(`${CMS_URL}/articles/${slug}`, {
next: {
tags: [`article:${slug}`, 'article:all'],
revalidate: 3600,
},
});
if (!res.ok) throw new Error(`Failed to load ${slug}`);
return res.json() as Promise<Article>;
}
Two tags on every article fetch: a narrow one (article:slug) and a broad one (article:all). The narrow tag lets us invalidate exactly one article. The broad tag lets us invalidate the index pages that list all articles when, say, a new one is published.
That dual-tag pattern is the whole game. Get the taxonomy right and you can invalidate exactly what you mean, no more, no less.
A tag taxonomy that survives contact with reality
After a few iterations, we settled on a four-level scheme:
- Entity-specific:
article:{slug},product:{sku},author:{id} - Collection:
article:all,product:all - Relationship:
author:{id}:articles,category:{id}:products - Surface:
nav:global,footer:global,homepage:hero
The surface tags were the late addition. We kept finding cases where a piece of content appeared in a chrome element — the global nav, a homepage carousel — and we needed a way to invalidate just that surface without re-fetching everything tagged article:all.
Wiring the webhook
The CMS sends a structured payload describing what changed. Our revalidation route translates that into a set of tags:
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
import { verifySignature } from '@/lib/webhook';
type CmsEvent = {
type: 'article' | 'product' | 'author';
action: 'publish' | 'update' | 'delete';
id: string;
slug?: string;
authorId?: string;
};
export async function POST(req: NextRequest) {
const body = await req.text();
if (!verifySignature(req, body)) {
return NextResponse.json({ ok: false }, { status: 401 });
}
const event = JSON.parse(body) as CmsEvent;
const tags = tagsForEvent(event);
for (const tag of tags) revalidateTag(tag);
return NextResponse.json({ ok: true, tags });
}
function tagsForEvent(e: CmsEvent): string[] {
switch (e.type) {
case 'article':
return [
`article:${e.slug}`,
'article:all',
e.authorId ? `author:${e.authorId}:articles` : null,
].filter(Boolean) as string[];
case 'product':
return [`product:${e.id}`, 'product:all'];
case 'author':
return [`author:${e.id}`, `author:${e.id}:articles`];
}
}
A typo fix on one article now invalidates two tags. The article page re-renders on its next request. The listing pages that include that article in a card re-render on theirs. Everything else stays warm.
In our experience, edge cache hit rate after a publish recovered in seconds rather than minutes, and origin RPS during a publish window dropped by roughly an order of magnitude.
The four gotchas
This is the part you actually came for.
1. revalidateTag doesn't invalidate the full route cache the way you think
revalidateTag marks data-cache entries stale. The next request for a page that used that data will re-fetch and re-render. But if a page doesn't fetch anything tagged — say, it's a static page that reads from a local file — revalidateTag will not touch it.
That sounds obvious until you have a page that uses a tagged fetch for one chunk and a non-tagged source for another. We had a homepage that pulled hero content from the CMS (tagged) and product recommendations from an internal service that hadn't been migrated to tagged fetches yet. Publishing a hero update invalidated the hero data but the rendered page in the route cache was still tied to the old recommendation snapshot.
The fix: audit every fetch in any route that you want to be tag-revalidatable, and tag them. If you can't tag a data source, wrap it in a server function with an explicit revalidate window and document that the page has two invalidation paths.
2. Tags are matched literally, including typos
There is no schema for tags. article:all and articles:all are two different tags, and if half your code uses one and half uses the other, revalidateTag will silently do nothing on the half it doesn't match.
We now generate tags through a small helper module:
// app/lib/tags.ts
export const tags = {
article: (slug: string) => `article:${slug}` as const,
articleAll: () => 'article:all' as const,
authorArticles: (id: string) => `author:${id}:articles` as const,
product: (sku: string) => `product:${sku}` as const,
productAll: () => 'product:all' as const,
} as const;
Every fetch and every revalidateTag call goes through this module. TypeScript catches the typo at compile time. Worth the ten minutes.
3. There is a soft limit on tag cardinality
Vercel's docs note a practical ceiling on the number of tags per cache entry and the total number of tags in your deployment. We don't have a hard public number, but we hit issues when we got cute and started tagging every fetch with locale:en-GB, region:emea, experiment:hero-v3, plus three entity tags. Some invalidations started behaving inconsistently.
Rule of thumb we settled on: at most three or four tags per fetch, and never tag with anything high-cardinality unless you genuinely need to invalidate by that dimension. "Could be useful someday" is not a reason to add a tag.
4. revalidateTag is fire-and-forget — and that bites in webhooks
revalidateTag returns synchronously but the actual invalidation propagates asynchronously across the edge. If your CMS fires two publish events for the same article within a second (a common pattern when an editor saves and then immediately publishes), the second one can arrive before the first has fully propagated.
We added a small debounce in front of the route — 500ms keyed by entity ID — and a short retry on the CMS side if the response is non-200. The debounce was the bigger win. It collapsed bursts of three or four events into one invalidation and noticeably reduced edge churn.
What we measure now
We log every revalidateTag call with the event that triggered it, then track three things in our dashboard:
- Tags invalidated per publish (target: low single digits)
- Edge cache hit rate over the five minutes after a publish
- Origin requests per second during publish windows
If any of these drift in the wrong direction, it usually means someone added a fetch without tagging it, or tagged it too broadly. The dashboard catches it before the on-call does.
Where we'd start
If you're sitting on a revalidatePath('/', 'layout') webhook today, don't try to refactor everything at once. Pick one content type — your highest-traffic one — and do this:
- Add a
tags.tshelper with typed constructors for that type. - Tag every
fetchfor that content with an entity tag and a collection tag. - Add a single
revalidateTagcall in your webhook for that type, behind a feature flag. - Watch cache hit rate for a week. Confirm the right pages update and the wrong ones don't.
- Then expand to the next content type.
The taxonomy is the hard part, not the API. Get the names right, generate them from a single source, and revalidateTag will quietly become one of the highest-leverage tools in your Next.js toolbox. If you'd rather have us help untangle a caching layer that's grown a few too many rings, that's the kind of work we do.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Streaming SSR and Suspense Boundaries: Where to Draw the Line
Streaming SSR is free performance — until it isn't. Where you place Suspense boundaries decides whether your page feels fast or stutters its way through a waterfall of spinners.

React Server Components and the Prop Serialization Wall
Server Components feel magical until you try to pass a Date, a class instance, or a function through the boundary. Here's how to design around the serialization wall instead of fighting it.

Partial Prerendering in Production: What Breaks When You Turn It On
Partial Prerendering looked like free performance on the demo slide. Then we shipped it. Here's what actually breaks when you flip the flag on a real Next.js app — and how we'd roll it out now.
