Cache Invalidation in Next.js App Router: A Field Guide
revalidateTag, revalidatePath, and the Data Cache look simple until you ship them. Here's how we reason about Next.js caching layers, what bites teams in production, and the mental model we wish we'd had on day one.

Cache invalidation in the Next.js App Router is the thing that looks like a one-liner in the docs and turns into a three-day debugging session in production. The primitives — revalidateTag, revalidatePath, cache: 'force-cache', unstable_cache — are fine. The mental model is what's missing.
This is the field guide we wish we'd handed every new engineer joining a Next.js project in the last two years.
The four caches you're actually dealing with
When someone says "the Next.js cache," they usually mean one of four different things. Conflating them is the root cause of most production confusion.
- Request Memoization — per-request deduplication of
fetchcalls inside a single render. Dies when the request ends. - Data Cache — the persistent server-side cache for
fetchresponses andunstable_cachevalues. Survives across requests and deployments (unless you opt out). - Full Route Cache — the static HTML and RSC payload generated at build or on first request for cacheable routes.
- Router Cache — the client-side, in-memory cache of RSC payloads that makes
<Link>navigations feel instant.
revalidateTag and revalidatePath operate on the Data Cache and Full Route Cache. They do not clear the client-side Router Cache for users who already have a tab open. We've seen this one ship to production more than once.
Why the distinction matters
If an editor updates a product in your CMS and you call revalidateTag('product:123') from a webhook, you've invalidated the server-side data. Great. But a user mid-session navigating back to /products/123 may still see the old RSC payload from their Router Cache until it expires (30 seconds for dynamic, 5 minutes for static by default, configurable via staleTimes in next.config.js).
The fix is usually some combination of router.refresh() after a mutation, a sensible staleTimes config, and accepting that "instant global invalidation" is not a thing.
Tag everything, path almost nothing
Our rule of thumb after a few large App Router builds: tag your fetches, avoid revalidatePath except for navigation-level changes.
revalidatePath is blunt. It invalidates the Full Route Cache for that path plus any data fetched on it. That sounds fine until you realise:
- It doesn't cascade to other routes that fetch the same data.
- It re-renders things you didn't need to re-render.
- It encourages coupling your cache strategy to your URL structure, which changes.
Tags are content-addressed. They survive refactors.
// lib/data/products.ts
import { unstable_cache } from 'next/cache';
export async function getProduct(id: string) {
return fetch(`${process.env.API_URL}/products/${id}`, {
next: {
tags: [`product:${id}`, 'product:all'],
revalidate: 3600,
},
}).then((r) => r.json());
}
export const getFeaturedProducts = unstable_cache(
async () => {
const res = await fetch(`${process.env.API_URL}/products/featured`);
return res.json();
},
['featured-products'],
{ tags: ['product:all', 'product:featured'], revalidate: 3600 }
);
Now a single webhook can do the right thing:
// app/api/webhooks/cms/route.ts
import { revalidateTag } from 'next/cache';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const { type, id } = await req.json();
if (type === 'product.updated') {
revalidateTag(`product:${id}`);
}
if (type === 'product.featured.changed') {
revalidateTag('product:featured');
}
return NextResponse.json({ ok: true });
}
One product update invalidates exactly the routes that depend on it — detail page, listing, recommendations — regardless of where those pages live in your file tree.
The gotchas that cost us real time
unstable_cache does not see request context
This is the single most common bug we see. unstable_cache runs in an isolated context. It cannot read cookies() or headers(). If you wrap a per-user function in unstable_cache, you'll either get an error or — worse, in older versions — silently leak one user's data to another.
If the data is per-user, either don't cache it server-side, or cache it with the user id baked into the key:
export const getUserDashboard = (userId: string) =>
unstable_cache(
async () => fetchDashboard(userId),
['dashboard', userId],
{ tags: [`user:${userId}:dashboard`], revalidate: 60 }
)();
Note the trailing () — unstable_cache returns a function, and forgetting to invoke it is another classic.
Server Actions don't auto-invalidate
A mutation in a Server Action does not magically refresh the UI. You have to tell Next what changed.
'use server';
import { revalidateTag } from 'next/cache';
import { z } from 'zod';
const UpdateSchema = z.object({
id: z.string(),
name: z.string().min(1).max(120),
});
export async function updateProduct(formData: FormData) {
const parsed = UpdateSchema.parse({
id: formData.get('id'),
name: formData.get('name'),
});
await db.product.update({
where: { id: parsed.id },
data: { name: parsed.name },
});
revalidateTag(`product:${parsed.id}`);
revalidateTag('product:all');
}
If the form is on the same route the user is viewing, that's usually enough — Next will re-fetch the RSC payload on the next render pass. If the user might be on a different tab or device, you need a separate signal (websocket, polling, or just accept eventual consistency).
Dynamic params silently disable static caching
Using cookies(), headers(), or searchParams inside a Server Component makes the route dynamic. The Full Route Cache won't be populated. We've seen teams add a single cookies() call for a feature flag at the layout level and wonder why their entire app suddenly stopped being static.
Isolate dynamic reads to the smallest component possible, wrap it in <Suspense>, and let the rest of the tree stay static. If you're on a recent Next version with Partial Prerendering available, this discipline pays off doubly.
CDN caching is a separate problem
revalidateTag does not invalidate Vercel's edge CDN, Cloudflare, or whatever sits in front of your app — unless you've wired it up. Read the docs for your host. On Vercel, revalidateTag is integrated; on a custom setup behind Cloudflare, you also need to purge by tag or URL.
A decision tree for picking a strategy
When a new piece of data comes up, we walk through this:
- Is it per-user? Don't use the Data Cache. Either no cache, or React's
cache()for per-request memoization only. - Does it change rarely and predictably? Use
fetchwithrevalidate(time-based) and a tag for manual busting. - Does it change via a known mutation point (CMS webhook, admin form)? Tag it, and call
revalidateTagfrom that mutation. - Is it expensive and shared across users?
unstable_cachewith a tag, and a reasonablerevalidateceiling as a safety net. - Is it real-time? Don't cache. Stream it, or use a client-side subscription.
The revalidate time floor is your safety net for the case where you forget to invalidate something. Never set it to false (cache forever) unless the data is genuinely immutable — for example, content addressed by a hash.
Observability, or you're flying blind
The Data Cache is invisible by default. You will not know if your tags are doing anything until something breaks. A few things that help in our experience:
- Log every call to
revalidateTagandrevalidatePathwith the tag, the trigger, and a timestamp. Cheap, invaluable in incident review. - Add a
X-Cache-Tagresponse header in development so you can see what tags a given page was built with. - In production, monitor your origin hit rate. A sudden spike usually means a tag is over-broad and an unrelated mutation is busting half your site.
// lib/cache/revalidate.ts
import { revalidateTag as nextRevalidateTag } from 'next/cache';
export function revalidateTag(tag: string, reason: string) {
console.info('[cache] revalidate', { tag, reason, at: Date.now() });
nextRevalidateTag(tag);
}
A thin wrapper costs nothing and gives you a real audit trail.
Where we'd start
If you're inheriting a Next.js codebase and the cache feels haunted, do three things this week. First, grep for every revalidatePath call and ask whether a tag would be more precise. Second, audit every unstable_cache for hidden per-user data. Third, write down — on an actual page in your repo — which tags exist, what they mean, and which mutations bust them. That document is worth more than any clever abstraction.
If you'd rather have someone else do the archaeology, our team does this kind of Next.js performance work regularly, and you can read more App Router breakdowns over on the blog.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Streaming Suspense Boundaries: Where to Put Them So TTFB Actually Drops
Suspense in the Next.js App Router is a TTFB lever, not a loading spinner. Here's how we decide where the boundaries go on real product pages — and where they backfire.

React 19's useOptimistic in Anger: Patterns That Survive Network Failures
useOptimistic feels magical in demos and brittle in production. Here's how we wire it up so optimistic UI doesn't lie to users when the network goes sideways.

Partial Prerendering in Next.js: When the Hype Meets Real Apps
Partial Prerendering promises the speed of static with the freshness of dynamic. Here's what actually happens when you ship it past a marketing site, and where it quietly bites.
