Cache Tags in the App Router: The Invalidation Story That Finally Works
Cache tags in Next.js finally give us a real invalidation model, but they punish sloppy tagging with stale data and surprise revalidations. Here's how we tag, invalidate, and debug them in production.
Cache tags are the piece of the App Router story that took the longest to click for our team. revalidatePath felt obvious, revalidateTag felt like magic — and magic, in production, is usually where the incidents start. After shipping three commerce and content platforms on this model, we've settled on a set of patterns that keep invalidation predictable without turning every route into a stale-data lottery.
This is the write-up we wish we'd had a year ago.
Why tags exist in the first place
revalidatePath invalidates a route. That's fine when your data maps neatly to URLs, but most apps don't. A product appears on the PDP, the category grid, the search page, the cart drawer, the admin dashboard, and three email preview routes. If a price changes, revalidatePath('/products/[slug]') misses five of those surfaces.
Tags flip the model. You tag the data when you fetch it, and you invalidate the tag when the data changes. Every route that touched that data gets a fresh render on next request. That's the pitch, and it holds up — provided you tag with discipline.
The mental model that stuck
We stopped thinking of tags as "labels on fetches" and started thinking of them as facts about the world. A tag like product:sku-1234 is an assertion that some cached bytes depend on that product. When the product changes, that assertion breaks, and every fetch carrying that tag has to be re-evaluated.
Once you frame it that way, two rules fall out naturally:
- Tags describe entities, not endpoints.
- Every mutation is responsible for invalidating every entity it touches.
A tagging convention that survives a team of ten
Ad-hoc tag strings rot fast. On our second project we had products, product, product-list, productDetail, and products:all all coexisting, none of them consistently invalidated. We now enforce a single shape:
// lib/cache-tags.ts
export const tags = {
product: (id: string) => `product:${id}` as const,
productList: (scope: 'all' | `category:${string}`) =>
`product-list:${scope}` as const,
inventory: (sku: string) => `inventory:${sku}` as const,
order: (id: string) => `order:${id}` as const,
user: (id: string) => `user:${id}` as const,
};
export type CacheTag = ReturnType<typeof tags[keyof typeof tags]>;
Every tag comes from this file. ESLint has a rule that forbids raw string literals passed to revalidateTag or the tags option of fetch. The type gives us autocomplete and a single grep target when we need to audit invalidation.
Entity tags vs. collection tags
The non-obvious distinction: individual entity tags and collection tags need different invalidation behavior.
product:sku-1234— invalidated when that specific product changes.product-list:all— invalidated when any product is created, deleted, or reordered, but not on a price tweak.
Mix these up and you either over-invalidate (killing your cache hit rate) or under-invalidate (shipping stale grids). We keep a table in the repo README that maps mutations to the tags they must touch. It's boring, and it's saved us from at least two production regressions.
Tagging fetches and unstable_cache
There are two places tags attach: the native fetch and unstable_cache (which is still the primary API for wrapping non-fetch work — database calls, SDK calls, computed values).
// app/products/[slug]/page.tsx
import { tags } from '@/lib/cache-tags';
async function getProduct(slug: string) {
const res = await fetch(`${API}/products/${slug}`, {
next: { tags: [tags.product(slug)], revalidate: 3600 },
});
if (!res.ok) throw new Error('product fetch failed');
return res.json() as Promise<Product>;
}
For a Postgres call through a client library, fetch isn't involved, so we wrap:
// lib/data/inventory.ts
import { unstable_cache } from 'next/cache';
import { tags } from '@/lib/cache-tags';
import { db } from '@/lib/db';
export const getInventory = (sku: string) =>
unstable_cache(
async () => db.inventory.findUnique({ where: { sku } }),
['inventory', sku],
{
tags: [tags.inventory(sku), tags.product(sku)],
revalidate: 60,
},
)();
Two things to notice. First, we tag inventory with both inventory:sku and product:sku, because a product-level invalidation should also nuke any derived inventory reads. Second, the cache key array (['inventory', sku]) is separate from tags — the key controls memoization, tags control invalidation. Confusing these is the number one bug we see in code review.
Invalidating from server actions
Mutations live in server actions, and this is where the discipline pays off. Every action ends with an explicit invalidation block:
// app/products/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
import { tags } from '@/lib/cache-tags';
export async function updateProductPrice(
sku: string,
price: number,
) {
await db.product.update({ where: { sku }, data: { price } });
// Entity changed
revalidateTag(tags.product(sku));
// Any grid showing this product's price
revalidateTag(tags.productList('all'));
}
export async function createProduct(input: NewProduct) {
const created = await db.product.create({ data: input });
// No entity tag to invalidate — it didn't exist yet.
// Collections that now include it, though:
revalidateTag(tags.productList('all'));
if (created.categoryId) {
revalidateTag(tags.productList(`category:${created.categoryId}`));
}
return created;
}
The createProduct case is the one people miss. New entities have no prior tag to bust, so you have to think about which collections they now belong to. We enforce this in code review with a checklist comment template.
A word on revalidatePath
We still use revalidatePath for one thing: sitewide layouts and metadata that don't map to any entity. Everything else is tag-based. Mixing the two on the same mutation is a code smell — pick a model and stay with it.
The gotchas that cost us real time
A few things that aren't in the docs, or aren't loud enough.
Tags don't propagate across the RSC/client boundary. If a client component uses useSWR or TanStack Query against a route handler, revalidateTag won't touch that cache. You need a client-side invalidation strategy too, usually a router.refresh() after the action resolves.
revalidateTag is fire-and-forget. It marks the cache entry stale on the next request; it doesn't rebuild eagerly. If your action returns and the user immediately navigates, the new render still has to fetch. Budget for that in your loading UI.
Tag cardinality matters. We had a project tagging every fetch with user:${userId}, which sounded reasonable until we realized the tag index was growing without bound and invalidation latency crept up. Tag entities that actually get invalidated. Per-user session data usually shouldn't be tagged at all — it belongs in a request-scoped cache or cookies()-gated fetch.
Draft mode bypasses tags entirely. Preview environments will happily show you fresh data and mask a broken invalidation. Always test tag behavior with draft mode off.
Debugging when tags don't fire
When a user reports "the price didn't update," we walk the same checklist:
- Grep for the tag string. Does the mutation call
revalidateTagwith the exact same value the fetch used? Typos here are silent. - Confirm the fetch is actually cached. A
fetchwithcache: 'no-store'or a dynamic function (cookies(),headers()) in the same render can opt the whole segment out of the data cache, making tags irrelevant. - Check for a stale
unstable_cachewrapper. The internal key array changing doesn't matter; if you deployed a version where the tag list changed, old cached entries keep their old tags until they expire. - Look at the deployment. On Vercel, the data cache is per-deployment for some content and shared for others. A rollback can resurrect "stale" data that was actually invalidated correctly on the previous deploy.
We added a tiny dev-only logger that wraps revalidateTag and prints every call. It's ugly, but pairing it with server logs of next: { tags } values makes mismatches obvious in about thirty seconds.
// lib/cache-tags.ts (dev only)
import { revalidateTag as _revalidateTag } from 'next/cache';
export function revalidateTag(tag: CacheTag) {
if (process.env.NODE_ENV !== 'production') {
console.log('[cache] revalidate', tag);
}
return _revalidateTag(tag);
}
Where we'd start
If you're adopting tags on an existing App Router project, don't try to tag everything on day one. Pick the two or three entities that generate the most "why is this stale?" tickets — usually products, articles, or user profiles — and cover them end to end: a centralized tag helper, tagged reads, an invalidation checklist on every mutation, and a dev logger. Once that loop feels boring, expand outward.
The payoff isn't a faster site (though hit rates usually climb). It's that stale data stops being a mystery. When something is wrong, you can point at a tag, a fetch, and a mutation, and the fix is obvious. That's the part of the App Router that finally feels like infrastructure instead of guesswork.
If you want a hand auditing an existing caching strategy or designing one from scratch, that's the kind of work our web development team does day in and day out.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Route Handlers vs Server Actions for Mutations: When Each One Actually Wins
Server Actions look like the obvious choice for every mutation in the App Router. They aren't. Here's how we decide between actions and route handlers on real projects, with the failure modes we've hit.
Streaming SSR and Suspense Boundaries: Where to Draw the Line
Streaming SSR is not a free win. Put Suspense in the wrong place and you'll ship a page that feels slower than the blocking version. Here's how we decide where the boundaries go.
The Client Bundle Creep: How Third-Party SDKs Quietly Doubled Our JS Payload
A war story about how six innocent-looking SDKs turned a lean Next.js App Router build into a 480 KB client bundle, and the audit playbook we now run before every launch.
