Originally published on tamiz.pro.
Next.js 15's App Router ships with four distinct caching layers, and confusing them is the single most common source of stale data, memory leaks, and mysterious 404s in production. This deep dive maps every layer — Request Memoization, the Data Cache, the Full Route Cache, and the Router Cache — then walks through the advanced patterns that actually work in production: tag-based invalidation, unstable_cache with custom keys, time-based revalidation (ISR), and cache-aware streaming. By the end, you'll know exactly which cache is serving a request, why it's serving it, and how to invalidate it deterministically.
- 1. The Four Caching Layers: A Mental Model
- 2. Layer 1 — Request Memoization
- 3. Layer 2 — The Data Cache
- 4. Layer 3 — The Full Route Cache
- 5. Layer 4 — The Router Cache
- 6. Advanced Pattern: Tag-Based Revalidation with
revalidateTag - 7. Advanced Pattern:
unstable_cachewith Custom Keys - 8. Advanced Pattern: Time-Based Revalidation (ISR) Done Right
- 9. Advanced Pattern: Cache-Aware Streaming and Suspense
- 10. Production Best Practices and Common Pitfalls
- Frequently Asked Questions
1. The Four Caching Layers: A Mental Model
Every request in the App Router passes through up to four independent caches. They have different lifetimes, different scopes, and different invalidation triggers. Treating them as one "Next.js cache" is the root of nearly every caching bug.
┌─────────────────────────────────────────────────────────────┐ │ Browser (Client) │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Layer 4: Router Cache (client-side, ~30s default) │ │ │ │ Stores RSC payloads for back/forward navigation │ │ │ └───────────────────────────────────────────────────────┘ │ └──────────────────────────┬──────────────────────────────────┘ │ HTTP request ┌──────────────────────────▼──────────────────────────────────┐ │ Next.js Server │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Layer 3: Full Route Cache (static HTML per route) │ │ │ │ Persists the rendered HTML + RSC payload on disk │ │ │ └───────────────────────────────────────────────────────┘ │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Layer 2: Data Cache (persistent, cross-request) │ │ │ │ Stores fetch() results + unstable_cache output │ │ │ └───────────────────────────────────────────────────────┘ │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Layer 1: Request Memoization (per-request, in-memory)│ │ │ │ Dedupes identical fetch() calls within one render │ │ │ └───────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ | Layer | Scope | Persistence | Default Lifetime | Primary Invalidation |
|---|---|---|---|---|
| Request Memoization | One render pass | In-memory | Request ends | None (automatic) |
| Data Cache | Global (server) | Persistent (disk/DB) | fetch default: 0s (no store); unstable_cache: infinite | revalidateTag, revalidatePath, time |
| Full Route Cache | Per-route | Persistent (disk) | Static routes: infinite | revalidatePath, rebuild, time |
| Router Cache | Per-client session | In-memory | 30s (default) | Navigation, router.refresh() |
Key insight: These layers are not alternatives. A single page request can be served from the Router Cache, then the Full Route Cache, then the Data Cache, then Request Memoization — in that order. Knowing which one is holding your stale data is step one.
2. Layer 1 — Request Memoization
Request Memoization is the simplest and least understood layer. During a single server render, Next.js deduplicates identical fetch() calls (and wrapped unstable_cache calls) so that a function called twice with the same arguments hits the network once.
// app/products/[id]/page.tsx export default async function ProductPage({ params }: { params: { id: string } }) { // If ProductCard and PriceChart both call fetchProduct(id), // the network request happens ONCE per render. const product = await fetchProduct(params.id); const price = await fetchPrice(params.id); return ( <div> <ProductCard product={product} /> <PriceChart price={price} /> </div> ); } async function fetchProduct(id: string) { const res = await fetch(`https://api.example.com/products/${id}`); return res.json(); } async function fetchPrice(id: string) { const res = await fetch(`https://api.example.com/products/${id}/price`); return res.json(); } What it does and doesn't do
- Dedupes identical
fetch()URLs + options within a single render pass. - Does NOT persist across requests. The next request re-fetches.
- Does NOT cache non-
fetchdata. If you query a database withprismaorpg, there is no memoization unless you wrap it inReact.cache().
The React.cache() escape hatch
For non-fetch data sources (DB queries, SDK calls, file reads), you can opt into per-request memoization yourself:
// lib/db.ts import { cache } from 'react'; export const getUser = cache(async (userId: string) => { const user = await prisma.user.findUnique({ where: { id: userId } }); return user; }); Now getUser('123') called ten times in one render executes the query once. This is a pure per-request optimization — it never survives the request, so it's safe to use liberally.
3. Layer 2 — The Data Cache
The Data Cache is the persistent, cross-request cache for fetch() results and unstable_cache outputs. This is where most advanced patterns live.
3.1 fetch() caching semantics in Next.js 15
Next.js 15 changed the default: fetch() is no longer cached by default. You must opt in explicitly.
// app/dashboard/page.tsx // ❌ NOT cached (Next.js 15 default) — hits the network on every request const res = await fetch('https://api.example.com/stats'); // ✅ Cached for 60 seconds (stale-while-revalidate) const res = await fetch('https://api.example.com/stats', { next: { revalidate: 60 }, }); // ✅ Cached indefinitely until manually invalidated const res = await fetch('https://api.example.com/stats', { next: { tags: ['stats'] }, }); // ✅ Never cached, always fresh const res = await fetch('https://api.example.com/stats', { cache: 'no-store', }); | Option | Behavior | Typical Use Case |
|---|---|---|
| (none) | No cache (Next 15 default) | Live data, auth-dependent data |
next: { revalidate: N } | Stale-while-revalidate, N seconds | Dashboards, listings |
next: { tags: [...] } | Cache forever until tag invalidated | CMS content, reference data |
cache: 'no-store' | Bypass entirely | Real-time, user-specific data |
cache: 'force-cache' | Force cache (legacy behavior) | Rare — prefer revalidate or tags |
3.2 Stale-while-revalidate (SWR) in the Data Cache
When you set revalidate: 60, Next.js serves the cached value immediately and triggers a background revalidation in the next request after 60 seconds. Users never wait for the network — they get the previous data, and the fresh data is swapped in on the subsequent request.
// app/products/page.tsx export default async function ProductsPage() { const products = await fetch('https://api.example.com/products', { next: { revalidate: 300 }, // 5 minutes }).then((r) => r.json()); return <ProductGrid products={products} />; } Trade-off: SWR gives you sub-second TTFB at the cost of serving data that's up to
revalidateseconds old. For a product catalog that's fine. For a stock ticker, it's not.
4. Layer 3 — The Full Route Cache
The Full Route Cache stores the rendered HTML and the RSC payload for statically rendered routes. It's why a static page can be served from a CDN without running any server code.
4.1 Static vs. dynamic rendering
A route is static (and thus eligible for the Full Route Cache) only if every data fetch in it is cacheable. A single cache: 'no-store' or a cookies()/headers() call makes the whole route dynamic, which disables the Full Route Cache for that route.
// app/account/page.tsx export default async function AccountPage() { // ❌ This makes the ENTIRE route dynamic. // The Full Route Cache is bypassed; every request re-renders. const session = await cookies(); const user = await fetch(`https://api.example.com/users/${session.get('uid')}`, { cache: 'no-store', }); return <Account user={user} />; } 4.2 Forcing static generation with generateStaticParams
For dynamic routes, you can pre-render known parameter combinations at build time:
// app/blog/[slug]/page.tsx import { getAllSlugs } from '@/lib/posts'; export async function generateStaticParams() { const slugs = await getAllSlugs(); return slugs.map((slug) => ({ slug })); } export default async function PostPage({ params }: { params: { slug: string } }) { const post = await fetch(`https://api.example.com/posts/${params.slug}`, { next: { revalidate: 3600 }, // 1 hour ISR }).then((r) => r.json()); return <article>{post.content}</article>; } Now /blog/hello-world is pre-rendered at build time, served from the Full Route Cache, and revalidated every hour — the classic ISR pattern.
5. Layer 4 — The Router Cache
The Router Cache lives in the browser. It stores the RSC payload of previously visited routes so that back/forward navigation is instant. It's ephemeral (per-client, cleared on refresh) and has a default stale time of 30 seconds.
// next.config.ts import type { NextConfig } from 'next'; const nextConfig: NextConfig = { experimental: { staleTimes: { dynamic: 0, // dynamic routes: never stale-cache static: 180, // static routes: 3 minutes }, }, }; export default nextConfig; Common bug: You update a database, call
revalidateTag, and the server data is fresh — but the browser still shows stale UI because the Router Cache is serving the old RSC payload. The fix isrouter.refresh()after a mutation, orrevalidatePathwithtype: 'layout'to force a full refetch.
6. Advanced Pattern: Tag-Based Revalidation with revalidateTag
Tags are the most powerful invalidation primitive in Next.js. They let you invalidate every cached fetch tagged with a string, from anywhere — a server action, a route handler, or a webhook — without knowing which route consumed it.
6.1 Tagging fetches
// app/products/page.tsx export default async function ProductsPage() { const products = await fetch('https://api.example.com/products', { next: { tags: ['products', 'catalog'] }, }).then((r) => r.json()); return <ProductGrid products={products} />; } 6.2 Invalidating from a Server Action
// app/products/actions.ts 'use server'; import { revalidateTag } from 'next/cache'; export async function deleteProduct(productId: string) { await prisma.product.delete({ where: { id: productId } }); // Every fetch tagged 'products' is now stale. // The next request to /products will re-fetch from the origin. revalidateTag('products'); } 6.3 Invalidating from a webhook (route handler)
// app/api/webhooks/cms/route.ts import { revalidateTag } from 'next/cache'; import { NextResponse } from 'next/server'; export async function POST(request: Request) { const payload = await request.json(); // Validate the webhook signature... if (!isValidSignature(payload)) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } // Invalidate the affected content — no need to know which pages use it. revalidateTag(payload.entityType); // e.g. 'blog-post', 'product' return NextResponse.json({ revalidated: true }); } 6.4 The composite tag pattern
Tag with both a collection and a single item so you can invalidate granularly or broadly:
// lib/data.ts const API = 'https://api.example.com'; export async function getProduct(id: string) { const res = await fetch(`${API}/products/${id}`, { next: { tags: [`product-${id}`, 'products'] }, }); return res.json(); } export async function getProducts() { const res = await fetch(`${API}/products`, { next: { tags: ['products'] }, }); return res.json(); } // app/products/[id]/actions.ts 'use server'; import { revalidateTag } from 'next/cache'; export async function updateProduct(id: string, data: ProductInput) { await prisma.product.update({ where: { id }, data }); // Invalidate just this product AND the whole list. revalidateTag(`product-${id}`); revalidateTag('products'); } Now updating one product refreshes its detail page and the listing page, but leaves other product detail pages untouched.
7. Advanced Pattern: unstable_cache with Custom Keys
fetch() only caches HTTP requests. For database queries, external SDK calls, or computed values, you need unstable_cache — the official escape hatch for arbitrary async functions.
7.1 The basic wrapper
// lib/analytics.ts import { unstable_cache } from 'next/cache'; export const getDailyRevenue = unstable_cache( async (startDate: string, endDate: string) => { // Any async work: Prisma, Drizzle, an SDK, a file read... const rows = await prisma.orders.groupBy({ by: ['date'], where: { date: { gte: startDate, lte: endDate } }, _sum: { amount: true }, }); return rows; }, // Cache key parts — must be serializable. ['daily-revenue'], // Options { revalidate: 900, // 15 minutes tags: ['analytics', 'revenue'], } ); 7.2 The critical gotcha: arguments are NOT part of the key
unstable_cache uses only the key parts array to build the cache entry. The function's arguments are ignored for keying purposes. If you call getDailyRevenue('2025-01-01', '2025-01-31') and then getDailyRevenue('2025-02-01', '2025-02-28'), both calls return the same cached result unless you include the arguments in the key.
// ❌ WRONG — both calls share one cache entry const getRevenue = unstable_cache( async (start: string, end: string) => queryRevenue(start, end), ['revenue'], { revalidate: 900 } ); await getRevenue('2025-01-01', '2025-01-31'); // caches under ['revenue'] await getRevenue('2025-02-01', '2025-02-28'); // ❌ returns January's data! // ✅ CORRECT — include the args in the key parts const getRevenue = unstable_cache( async (start: string, end: string) => queryRevenue(start, end), (start, end) => ['revenue', start, end], // dynamic key function { revalidate: 900 } ); await getRevenue('2025-01-01', '2025-01-31'); // caches under ['revenue', '2025-01-01', '2025-01-31'] await getRevenue('2025-02-01', '2025-02-28'); // ✅ separate entry The key function receives the same arguments as the wrapped function and must return a serializable array. This is the #1 source of subtle bugs with unstable_cache.
7.3 Combining unstable_cache with React.cache()
Use both for maximum safety: React.cache() dedupes within a request; unstable_cache persists across requests.
// lib/user.ts import { cache } from 'react'; import { unstable_cache } from 'next/cache'; const _getUser = unstable_cache( async (id: string) => prisma.user.findUnique({ where: { id } }), (id) => [`user-${id}`], { tags: ['users'], revalidate: 3600 } ); export const getUser = cache((id: string) => _getUser(id)); Now the same user fetched in ten components during one render hits the cache once, and the cached result survives across requests for an hour.
8. Advanced Pattern: Time-Based Revalidation (ISR) Done Right
Incremental Static Regeneration (ISR) is the time-based counterpart to tag-based invalidation. It's ideal when you can tolerate up to N seconds of staleness but want zero server load on the critical path.
8.1 Per-fetch revalidation vs. per-route revalidation
You can set revalidation at the fetch level (as we've seen) or at the route level via the revalidate export. Route-level revalidation applies to all fetches in the route that don't specify their own.
// app/blog/page.tsx // Applies to every fetch in this route unless overridden. export const revalidate = 3600; // 1 hour export default async function BlogPage() { const posts = await fetch('https://api.example.com/posts'); // inherits 3600 return <PostList posts={posts} />; } 8.2 On-demand revalidation with revalidatePath
Time-based ISR is passive. When you need to invalidate immediately (e.g. a user publishes a post), use revalidatePath:
// app/admin/actions.ts 'use server'; import { revalidatePath } from 'next/cache'; export async function publishPost(postId: string) { await prisma.post.update({ where: { id: postId }, data: { published: true } }); // Revalidate the post page and the blog index immediately. revalidatePath(`/blog/${postId}`); revalidatePath('/blog'); } 8.3 The hybrid pattern: time + tags
For most real apps, combine both: use tags for deterministic invalidation, and set a generous revalidate as a safety net so stale data self-heals even if a webhook fails.
const getProduct = unstable_cache( async (id: string) => fetchProductFromDb(id), (id) => [`product-${id}`], { revalidate: 86400, // 24h safety net tags:...
