Next.js App Router caching: the four layers you need to know
Caching in the App Router is powerful and confusing in equal measure. Here is the map I wish I had from day one.
When I moved a project from the Pages Router to the App Router, everything worked fine until it did not. Pages that should have been fresh were stale. Pages I wanted cached were refetching on every request. The behavior was not random — it followed rules I did not yet understand.
The App Router has four distinct caching layers. They interact. Here is the map.
#Layer 1: Request Memoization (within a render)
During a single render pass, fetch calls with the same URL and options return the same result. This is not a user-facing cache — it lives only for the duration of one request.
// both of these hit the network exactly once per request
async function Header() {
const user = await fetch('/api/me').then(r => r.json());
return <span>{user.name}</span>;
}
async function Sidebar() {
const user = await fetch('/api/me').then(r => r.json()); // deduped
return <nav>{user.links}</nav>;
}This is why you can call the same fetch in multiple components without coordinating them. Next.js deduplicates for you at the render level.
Control: You cannot opt out. It is always on, always scoped to one request.
#Layer 2: Data Cache (persistent, across requests)
The Data Cache stores fetch responses on disk and serves them to future requests. This is where static and dynamic behavior diverges.
// cached indefinitely (default for static routes)
const data = await fetch('https://api.example.com/posts');
// cached for 60 seconds, then revalidated in the background
const data = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 },
});
// never cached — opt out completely
const data = await fetch('https://api.example.com/posts', {
cache: 'no-store',
});On-demand revalidation: Purge specific cache entries with revalidatePath or revalidateTag inside a Server Action:
'use server';
import { revalidateTag } from 'next/cache';
export async function updatePost(id: string) {
await db.posts.update(id, { /* fields */ });
revalidateTag(`post-${id}`);
}Tag your fetches, then invalidate by tag. The data cache updates on the next request.
#Layer 3: Full Route Cache (build-time static HTML)
At build time, next build renders every static route to HTML and an RSC payload. When a user requests that route, the CDN serves the file instantly — no server, no database.
A route becomes dynamic and bypasses this cache when:
- Any
fetchinside it usescache: 'no-store' - You read dynamic functions:
headers(),cookies(), orsearchParams - You call
noStore()fromnext/cache
export async function generateStaticParams() {
const posts = await getAllPostSlugs();
return posts.map(slug => ({ slug }));
}
export default async function Post({ params }) {
const post = await getPost(params.slug);
return <article>{post.content}</article>;
}generateStaticParams tells Next.js which slugs to pre-render at build time. Everything else is static by default.
Important: Updating the Data Cache (Layer 2) does not automatically invalidate the pre-rendered HTML (Layer 3). You need revalidatePath to clear both.
#Layer 4: Router Cache (client-side, in-memory)
The Router Cache lives in the browser. It stores RSC payloads for routes the user has visited, so that navigating back feels instant.
User visits /blog → fetched, stored in Router Cache
User navigates to /about
User navigates back to /blog → served from Router Cache (no network request)Lifetime:
- Static route segments: 5 minutes
- Dynamic route segments: 30 seconds
A Server Action that calls revalidatePath also invalidates the client cache — this is why mutations inside Server Actions feel immediate. Without it, the user navigates back to a page and sees the old RSC payload.
#How the layers interact
Build time → Full Route Cache (pre-rendered HTML + RSC payload)
↑
revalidatePath clears both
↑
Per-request → Data Cache (persistent fetch results)
↑
revalidateTag, revalidatePath
↑
Per-render → Request Memoization (automatic, no control needed)
↑
Client → Router Cache (in-browser RSC payload store)A mutation that calls revalidatePath('/blog') clears the pre-rendered HTML, the data, and the client cache. The next request rebuilds everything from the database.
#Practical defaults
Blog or marketing site: Let everything be static. Use ISR (revalidate: 3600) for content that changes occasionally. Reserve no-store for routes with user-specific data.
Dashboard: Opt routes into dynamic rendering with export const dynamic = 'force-dynamic'. Use Server Actions with revalidatePath for all mutations.
The caching model is opt-out, not opt-in. Understand the defaults first, then decide what to override.
Four layers, different lifetimes, overlapping invalidation. Once the map is clear, the behavior that looked random starts to make sense — and you spend less time fighting the framework.