All articles
Web DevelopmentMay 21, 2026 6 min read

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 Next.js App Router: A Field Guide

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.

  1. Request Memoization — per-request deduplication of fetch calls inside a single render. Dies when the request ends.
  2. Data Cache — the persistent server-side cache for fetch responses and unstable_cache values. Survives across requests and deployments (unless you opt out).
  3. Full Route Cache — the static HTML and RSC payload generated at build or on first request for cacheable routes.
  4. 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 fetch with revalidate (time-based) and a tag for manual busting.
  • Does it change via a known mutation point (CMS webhook, admin form)? Tag it, and call revalidateTag from that mutation.
  • Is it expensive and shared across users? unstable_cache with a tag, and a reasonable revalidate ceiling 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 revalidateTag and revalidatePath with the tag, the trigger, and a timestamp. Cheap, invaluable in incident review.
  • Add a X-Cache-Tag response 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.

#Next.js#React#Caching#Performance#App Router

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project