Caching in Next.js has always been powerful. But since the App Router, it has also become deeply layered, sometimes confusing, and — if you get it wrong — quietly responsible for stale data, broken UIs, and slow apps.
This guide covers everything: React's cache() function, Next.js's new use cache directive, cacheTag, revalidateTag, unstable_cache, the full request lifecycle, and the mental models you need to reason about all of it confidently.
By the end, you will know exactly what caches exist in Next.js, when each one activates, how they interact, and the practical patterns that separate production-grade apps from the rest.
Related: If you want implementation-ready patterns, production-tested snippets, and architectural diagrams specifically for caching in Next.js App Router apps, I go much deeper in the Next.js Caching Handbook — built for Next.js developers shipping real products.
Why caching in Next.js is different (and harder) than you think
Most developers approach caching reactively. Something is slow, so they add a cache. Something is stale, so they bust the cache. This works fine in simple apps but falls apart fast in Next.js, for one reason: there are multiple caches operating simultaneously at different layers, and they do not always know about each other.
Here are the four primary caches you are dealing with in a Next.js App Router application:
Request Memoization — deduplicates identical
fetch()calls within a single render passData Cache — persists fetch results across requests (server-side, file-system-backed)
Full Route Cache — stores rendered HTML + RSC payloads for static routes
Router Cache — client-side cache of visited route segments in the browser
Each one has its own lifetime, its own invalidation mechanism, and its own failure modes. Understanding what each does — and does not — cache is the foundation everything else builds on.
Layer 1: Request Memoization
Request Memoization is the most misunderstood cache in Next.js, because it looks like a data cache but is not.
What it does: During a single server render, if you call fetch("https://api.example.com/user/1") in three different components, Next.js only makes one actual HTTP request. The result is shared across all three.
What it does not do: It does not persist across requests. When the next user loads the page, the memoization table is wiped and fresh fetches happen.
Scope: Single render tree, single request.
When it activates: Automatically, for all fetch() calls made with identical URLs and options during a server-side render.
This is why you can safely call getUser() at the top of multiple server components without worrying about N+1 HTTP requests. Next.js deduplicates them for you.
// Both components call the same URL — only ONE HTTP request is made
async function Header() {
const user = await fetch('/api/me').then(r => r.json())
return <div>Welcome, {user.name}</div>
}
async function Sidebar() {
const user = await fetch('/api/me').then(r => r.json())
return <div>Profile: {user.avatar}</div>
}
React cache() — manual memoization for non-fetch data
fetch() gets automatic memoization. But what about database queries, SDK calls, or anything that doesn't use fetch()?
That's what React.cache() is for.
import { cache } from 'react'
import { db } from '@/lib/db'
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } })
})
Now getUser("abc") called from any server component in the same render will only hit the database once. React deduplicates by function reference + serialized arguments.
Key rules for cache():
Only works in server components and server-side code
The cache is per-request — not persistent across requests
Arguments must be serializable (strings, numbers, plain objects)
The function must be defined at module scope, not inside components
cache() is best used in your data layer — create one getUser, one getPost, one getCart, wrap each with cache(), and call them freely from anywhere in the server component tree.
Layer 2: The Data Cache
The Data Cache is where things get genuinely persistent. Unlike request memoization, the Data Cache survives across requests and is stored on the server (in Next.js's file-system cache or an external cache depending on your deployment).
By default, all fetch() calls in Next.js App Router are cached indefinitely in the Data Cache. This is the behavior that catches most developers off-guard when migrating from the Pages Router.
// This response is cached indefinitely by default
const data = await fetch('https://api.example.com/posts')
// Opt out of Data Cache entirely
const data = await fetch('https://api.example.com/posts', { cache: 'no-store' })
// Cache but revalidate every 60 seconds
const data = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 }
})
Time-based revalidation
next: { revalidate: N } tells Next.js to treat this cache entry as stale after N seconds. On the next request after expiry, Next.js will serve the stale response immediately (so the user doesn't wait) while revalidating in the background. This is Stale-While-Revalidate behavior.
async function BlogPosts() {
// Fresh for 10 minutes, then background-revalidated
const posts = await fetch('/api/posts', { next: { revalidate: 600 } })
return <PostList posts={await posts.json()} />
}
On-demand revalidation with revalidatePath and revalidateTag
Time-based revalidation works, but for content-driven apps you usually want to revalidate when something changes, not on a schedule.
revalidatePath('/blog') purges all cached data for that route.
revalidateTag('posts') purges all cached entries that were tagged with "posts".
Tags are more precise and composable. Here is how you assign them:
const data = await fetch('/api/posts', {
next: { tags: ['posts'] }
})
And here is how you invalidate them — typically in a Server Action or API route:
'use server'
import { revalidateTag } from 'next/cache'
export async function publishPost(id: string) {
await db.post.update({ where: { id }, data: { published: true } })
revalidateTag('posts') // purge all cached data tagged 'posts'
}
This is the pattern to reach for in CMS-backed sites, e-commerce, dashboards — any app where data changes on user action.
Layer 3: The Full Route Cache
The Full Route Cache stores the rendered output of static routes — the HTML and RSC payload — on disk. This is what makes Next.js apps incredibly fast to serve: for static routes, Next.js skips rendering entirely and streams bytes directly from disk.
Static routes are generated at build time unless you opt into dynamic rendering. A route becomes dynamic when it uses:
cookies(),headers(), orsearchParamsfetch()withcache: 'no-store'Any dynamic function
If your route has none of these, it renders once at build time and gets cached forever (until you redeploy or revalidate).
When the Full Route Cache is invalidated
On every new deployment
When
revalidatePath()is called for that routeWhen a tagged fetch used on that route is invalidated via
revalidateTag()
The Full Route Cache and the Data Cache are linked. When the Data Cache for a fetch used in a page is invalidated, Next.js will also regenerate the Full Route Cache for that page on the next request. This is Incremental Static Regeneration (ISR) — updated and alive in the App Router.
Layer 4: The Router Cache
The Router Cache lives in the browser. When a user navigates between routes in a Next.js app, the prefetched and visited route segments are stored in memory in the client. Navigating back to a previously visited page is instant — no network request.
Default lifetimes:
Static route segments: 5 minutes
Dynamic route segments: 30 seconds
This cache exists entirely in the browser and cannot be accessed or controlled from the server. It is invalidated when:
The user refreshes the page
router.refresh()is called from a client componentA Server Action with
revalidatePathorrevalidateTagruns (Next.js automatically invalidates the relevant router cache entries)
A common gotcha: after a Server Action updates data on the server, the Router Cache may still show stale content. Calling revalidatePath() inside the Server Action tells Next.js to expire the relevant router cache entries, which triggers a fresh fetch on the client.
The use cache directive (Next.js 15+)
use cache is the new, unified caching primitive introduced in Next.js 15 as part of the "dynamicIO" model. It is designed to replace the patchwork of fetch options, unstable_cache, and manual cache wrappers with a single, ergonomic API.
You can apply use cache to:
An entire file (all exports become cacheable)
An individual async function
An async server component
// Cache a specific function
async function getPosts() {
'use cache'
return db.post.findMany({ where: { published: true } })
}
// Cache an entire component
async function BlogList() {
'use cache'
const posts = await getPosts()
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
cacheTag — tagging use cache entries
cacheTag is the companion API to use cache. It lets you attach string tags to cached function results, enabling precise on-demand invalidation with revalidateTag.
import { unstable_cacheTag as cacheTag } from 'next/cache'
async function getPost(id: string) {
'use cache'
cacheTag(`post:${id}`, 'posts')
return db.post.findUnique({ where: { id } })
}
Now revalidateTag('posts') invalidates all posts. revalidateTag('post:abc') invalidates only the post with id abc. You can be as granular as your use case demands.
cacheLife — controlling cache duration
cacheLife lets you set the lifetime of a use cache entry using named profiles or explicit values:
import { unstable_cacheLife as cacheLife } from 'next/cache'
async function getHomepageData() {
'use cache'
cacheLife('hours') // built-in profile
return fetchHeavyData()
}
Built-in profiles: 'seconds', 'minutes', 'hours', 'days', 'weeks', 'max'.
You can also define custom profiles in next.config.ts:
const nextConfig = {
experimental: {
cacheLife: {
editorial: {
stale: 60 * 60, // 1 hour
revalidate: 60 * 60 * 4, // 4 hours
expire: 60 * 60 * 24 * 7, // 1 week
}
}
}
}
Then use it: cacheLife('editorial').
unstable_cache — the predecessor to use cache
Before use cache, unstable_cache was the way to cache non-fetch async functions. It is still widely used and supported.
import { unstable_cache } from 'next/cache'
const getCachedUser = unstable_cache(
async (id: string) => db.user.findUnique({ where: { id } }),
['user'], // cache key segments
{
tags: ['users'],
revalidate: 3600,
}
)
The key differences from use cache:
unstable_cachewraps the function at definition time.use cacheis applied inline.unstable_cacherequires explicit key segments.use cachederives the key automatically from function arguments.use cacheis simpler but requiresexperimental.dynamicIO: trueinnext.config.ts.
For new projects on Next.js 15, prefer use cache. For existing projects or where dynamicIO is not enabled, unstable_cache is the right tool.
Opting out of caching
Knowing how to opt out is just as important as knowing how to opt in.
// Per-fetch opt-out
fetch('/api/data', { cache: 'no-store' })
// Route-level opt-out — makes the entire route dynamic
export const dynamic = 'force-dynamic'
// Revalidation only, no persistent cache
export const revalidate = 0
When should you opt out?
Real-time data (prices, live scores, user-specific dashboards)
Auth-gated pages with personalized content
Any route where stale data would cause functional bugs
Common caching mistakes (and how to fix them)
1. Caching user-specific data globally
Never cache responses that differ per user (session data, personalized feeds, account info) in the Data Cache or with use cache at the page level. Cache the data-fetching layer and pass user context in, or opt those routes out of caching entirely.
2. Forgetting revalidateTag in Server Actions
A Server Action that mutates data without calling revalidateTag or revalidatePath will leave the Data Cache and Router Cache stale. Always pair mutations with cache invalidation.
3. Using cache() for persistent caching
React.cache() is per-request memoization, not persistent caching. Using it to "cache" a database result across requests has no effect — the result is thrown away after each render.
4. Tagging too broadly
If you tag every fetch with 'all' and call revalidateTag('all') on every mutation, you lose all the benefits of granular invalidation. Tag specifically: post:${id}, user:${id}, category:${slug}.
5. Ignoring the Full Route Cache in production
Static routes work differently locally (next dev always renders dynamically) versus production (next build). Test caching behavior with next build && next start, not just in dev mode.
A practical mental model for Next.js caching
Think of it as four nested layers:
Request
└─ Request Memoization (within a single render, ephemeral)
└─ Data Cache (across requests, persistent, server-side)
└─ Full Route Cache (rendered HTML, per route, server-side)
└─ Router Cache (browser, in-memory, per session)
Invalidation flows outward: when the Data Cache for a fetch is invalidated, the Full Route Cache that depends on it gets regenerated. When the Full Route Cache is updated, the Router Cache for that route gets expired on the next Server Action or navigation.
Cache as close to the data source as possible. Tag specifically. Invalidate on mutation. Opt out for truly dynamic content.
Summary: when to use what
Scenario
Tool
Deduplicate DB calls within a render
React.cache()
Cache fetch results across requests
fetch() with next: { revalidate }
Cache non-fetch async functions
unstable_cache or use cache
Tag-based on-demand invalidation
cacheTag + revalidateTag
Invalidate a whole route after mutation
revalidatePath
Skip caching for real-time data
cache: 'no-store' or dynamic = 'force-dynamic'
Control cache lifetime with profiles
cacheLife
Going deeper
This guide covers every caching concept in the Next.js App Router. But knowing the concepts and applying them correctly in a production codebase are two different things.
If you're building a real Next.js application and want:
Architecture diagrams showing how the four cache layers interact
Production-ready patterns for e-commerce, SaaS, and content sites
Recipes for cache warming, segment-level caching, and multi-tenant apps
A decision tree for every caching choice you'll face
Code snippets you can drop straight into your app
...then the Next.js Caching Handbook is built exactly for that. It's a code-first, production-focused guide for Next.js developers who want to cache correctly from the start.
Published by Emeruche Ikenna. If this helped you, share it with a Next.js developer who's been burned by stale cache.

Top comments (0)