Caching and Revalidation in Next.js
- nextjs
- react
- webdev
- typescript
- caching
- performance
- softwareengineering
In Data Fetching in the App Router, I covered where and how to fetch data in Server Components, Route Handlers, and Server Actions. That article deliberately sidestepped one question: once you've fetched something, how long does Next.js keep it around, and who decides when it goes stale? This is that article, and it's part of the React & Next.js Complete Guide, a series that starts from React Fundamentals for Professionals and builds toward production Next.js apps.
Caching in the App Router is the part of Next.js that trips up teams who otherwise know React well. There are four separate caches here, layered on top of each other, each with its own scope, lifetime, and rules for invalidation. Get the mental model wrong and you'll either serve stale data to users who expect it fresh, or accidentally opt every page into dynamic rendering and lose the performance you were chasing in the first place.
I want to walk through each layer briefly, then spend most of the time on revalidation, because that's where production incidents actually happen. Caching that never invalidates is a bug waiting for a support ticket.
The four caching layers, and why they don't behave the same way
Next.js caches at four distinct points. Conflating them is the single most common source of confusion:
Request Memoization dedupes identical fetch calls within a single render pass. If three components in the same tree call fetch('/api/user/42') during one request, Next.js makes the network call once and reuses the result for all three. This cache is scoped to a single request and is gone the moment the response is sent. It only applies to fetch, not arbitrary database calls; use React's cache() function for those.
Data Cache persists fetch results across requests and deployments, on the server, until you explicitly invalidate them or a revalidate window expires. This is the one people mean when they say "Next.js caches too aggressively." By default, fetch calls in Server Components are cached indefinitely unless you opt out.
Full Route Cache stores the rendered HTML and RSC payload for statically rendered routes at build time, or on first request for routes rendered on demand. It's what lets a page be served without touching your server at all on subsequent requests.
Router Cache lives in the browser and holds RSC payloads for routes you've visited or Next.js has prefetched via <Link>. It's what makes back/forward navigation and repeat visits within a session feel instant, without asking the server again.
The practical implication: fixing "my data is stale" requires knowing which layer is actually holding the stale copy. Clearing the Data Cache doesn't touch the Router Cache in the user's browser tab. Revalidating a route doesn't help if the component used cache: 'no-store' and was never cached to begin with; in that case the staleness is coming from somewhere else, like a CDN in front of your app.
Static, dynamic, and the decision that shapes everything else
Every route segment in the App Router is either static or dynamic, and the decision happens automatically based on what the segment does. Using cookies(), headers(), searchParams, or a fetch with cache: 'no-store' makes a segment dynamic. Avoiding those, and using cached fetches, keeps it static.
// app/products/[id]/page.tsx
async function getProduct(id: string) {
const res = await fetch(`https://api.internal.example.com/products/${id}`, {
next: { revalidate: 3600, tags: [`product-${id}`] },
});
if (!res.ok) {
throw new Error(`Failed to load product ${id}`);
}
return res.json();
}
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
const product = await getProduct(params.id);
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>{product.price}</span>
</article>
);
}
This page is static: it fetches with a revalidate window and no dynamic APIs, so Next.js can pre-render it and serve the cached HTML on subsequent requests. If I added a call to cookies() to check the logged-in user's currency preference, the whole segment would flip to dynamic, rendered fresh on every request, with no Full Route Cache benefit at all.
You can force the decision explicitly with route segment config, which is worth doing when the behavior needs to be obvious in code review rather than inferred from whatever APIs happen to get called:
export const dynamic = 'force-dynamic';
// or
export const dynamic = 'force-static';
I'd lean toward being explicit for anything with business-critical caching behavior. Implicit dynamic detection is convenient until someone adds a headers() call deep in a shared component and silently turns a cached marketing page into an uncached one.
Time-based revalidation: the default that works until it doesn't
Setting next: { revalidate: N } on a fetch, or export const revalidate = N at the segment level, gives you incremental static regeneration: the cached version is served immediately, and Next.js regenerates it in the background after the window expires, swapping in the fresh version for subsequent requests. Nobody waits on a synchronous rebuild.
This is the right default for content that changes on a predictable, tolerable cadence: product catalogs, blog listings, pricing pages. It's the wrong tool when staleness has a real cost. A revalidate: 60 window on inventory counts means a customer can order something that sold out fifty seconds ago. On a SaaS billing page reflecting a plan change, a minute of staleness after checkout is the kind of thing that generates a support ticket titled "I paid and nothing changed."
The tradeoff is really about who bears the cost of staleness versus the cost of load. A shorter window means fresher data and more origin requests; a longer window means cheaper serving and more risk of showing something wrong. There's no universal right number, only one that matches how much staleness a specific feature can tolerate.
On-demand revalidation: making the cache invalidate on your terms
Time-based revalidation assumes changes happen on a schedule. Most real changes don't: an admin edits a product, a webhook confirms a payment, a user updates their profile. For these, invalidate the cache the moment the change happens rather than waiting for a window to expire.
revalidateTag and revalidatePath do this, and they're the tools I reach for most in production. Tag-based invalidation is the more precise of the two: it invalidates exactly the data that changed without guessing which routes rendered it.
// app/api/webhooks/inventory/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';
export async function POST(request: NextRequest) {
const signature = request.headers.get('x-webhook-signature');
if (!isValidSignature(signature, await request.text())) {
return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
}
const payload = await request.json();
const { productId } = payload;
revalidateTag(`product-${productId}`);
return NextResponse.json({ revalidated: true });
}
Every fetch tagged with product-${productId} anywhere in the app is invalidated by this one call, regardless of which route rendered it. That's the advantage over revalidatePath, which invalidates a specific route path and everything rendered under it; useful when you know exactly which page needs a fresh render, but it can over-invalidate if the same data is fetched from multiple, unrelated routes.
A pattern worth adopting early: tag by entity, not by route. product-${id} survives a route restructuring; /products/${id} doesn't. Tagging by path is tempting because it's the first thing that works, but if a later redesign moves the product detail page, every webhook-driven invalidation that referenced the old path quietly stops doing anything.
Production pitfalls that don't show up in a demo
Caching request-scoped data as if it were global. If a fetch inside a Server Component reads a value that varies per user, per tenant, or per session, and you don't scope the cache key or use no-store, you can end up serving Tenant A's data to Tenant B. That's a data leak and a genuine security incident, well past a UX annoyance. Anything backed by cookies() or an auth token should either be dynamic or have its cache key include the tenant/user identity explicitly.
Assuming revalidatePath clears the client Router Cache too. It doesn't, not immediately for every client. A user sitting on a page with a prefetched RSC payload can still see stale content until they trigger a fresh navigation. If a workflow requires fresh data right after a mutation (a form submit, a settings change), pair server revalidation with a client-side router.refresh().
Treating revalidate: 0 and cache: 'no-store' as interchangeable. They land in similar places for many use cases, but revalidate: 0 still goes through the Data Cache machinery with a zero-second window, while no-store skips it entirely. For data that should never be cached, no-store is the more explicit statement of intent, and it's less likely to accidentally start caching under configuration changes elsewhere in the app.
Over-tagging or under-tagging fetches. Tag too coarsely and every unrelated update invalidates far more than it should, hurting your cache hit rate for no benefit. Tag too narrowly and you end up hunting down every place a piece of data is fetched to confirm the invalidation actually reaches it. Start with tags scoped to the entity ID, and add broader tags only where you've observed an actual invalidation gap.
Key takeaways
- Next.js has four distinct caches (Request Memoization, Data Cache, Full Route Cache, Router Cache), and "my data is stale" almost always means one specific layer needs attention, not all of them.
- Whether a route segment is static or dynamic is decided by what APIs it touches (`cookies()`, `headers()`, `no-store` fetches); make that decision explicit with route segment config for anything business-critical.
- Time-based revalidation (`revalidate: N`) fits predictable, tolerable staleness; on-demand revalidation (`revalidateTag`, `revalidatePath`) fits changes triggered by real events like webhooks and admin actions.
- Tag fetches by entity identity, not by route path, so invalidation still works after a redesign moves the page.
- Never cache request-scoped, tenant-specific, or auth-sensitive data without scoping the cache key or opting out entirely; this is the class of bug that turns into a data leak.
- `revalidatePath` and `revalidateTag` invalidate the server-side Data Cache; they don't automatically clear a user's client-side Router Cache, so pair server revalidation with `router.refresh()` where freshness right after a mutation genuinely matters.
Frequently asked questions
What's the difference between revalidateTag and revalidatePath?
`revalidateTag` invalidates every cached fetch carrying a specific tag, wherever it's used across the app. `revalidatePath` invalidates a specific route and the segments rendered under it. Tag-based invalidation is more precise when the same data appears on multiple routes; path-based is simpler when you know exactly which page needs to be fresh.
Does setting revalidate: 0 disable caching completely?
No. It still goes through the Data Cache with an effectively zero-second window, which behaves differently from `cache: 'no-store'` in some edge cases around build-time and config changes. If the intent is "never cache this," use `no-store` explicitly instead of relying on a zero-second window.
Why does my page still show old data after I call revalidateTag from a webhook?
Server-side revalidation clears the Data Cache and Full Route Cache, but a client with an already-prefetched Router Cache entry can keep serving it until a fresh navigation happens. If immediate freshness matters after a mutation, call `router.refresh()` from the client alongside the server-side revalidation.
Is it safe to cache fetches that depend on the logged-in user?
Only if the cache key accounts for the user or tenant, or you're using request-level memoization that never crosses a request boundary. Caching user-specific data without scoping the key by identity risks serving one user's data to another; when in doubt, make that fetch dynamic or use `no-store`.
How do I choose a revalidate window for time-based revalidation?
Base it on how much staleness the feature can tolerate, not a general rule of thumb. Content where being an hour behind is harmless, like marketing pages, can use a long window. Anything where staleness has a direct cost, like inventory or billing state, is a better candidate for on-demand revalidation tied to the event that changed the data.
Can I mix static and dynamic rendering within the same page?
Yes, using Suspense boundaries: a static shell renders immediately from the cache, while dynamic segments stream in once request-time data resolves. This gets the perceived speed of a static page without forcing every piece of content on it to be equally static.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.