• nextjs
  • caching
  • performance

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.

Mar 12, 2026·7 min read

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:

app/actions.ts
'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 fetch inside it uses cache: 'no-store'
  • You read dynamic functions: headers(), cookies(), or searchParams
  • You call noStore() from next/cache
app/blog/[slug]/page.tsx
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.