Next.js Caching Explained

March 4, 2026
A complete guide to caching in Next.js — covering every layer, every API, and the best practices that actually work in production. Every Strategy You Need to Know (React cache, use cache, cacheTags & More)
Next.js Caching Explained: Every Strategy You Need to Know (React cache, use cache, cacheTags & More)

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:

  1. Request Memoization — deduplicates identical fetch() calls within a single render pass
  2. Data Cache — persists fetch results across requests (server-side, file-system-backed)
  3. Full Route Cache — stores rendered HTML + RSC payloads for static routes
  4. 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(), or searchParams
  • fetch() with cache: '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 route
  • When 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 component
  • A Server Action with revalidatePath or revalidateTag runs (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_cache wraps the function at definition time. use cache is applied inline.
  • unstable_cache requires explicit key segments. use cache derives the key automatically from function arguments.
  • use cache is simpler but requires experimental.dynamicIO: true in next.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.

You might also like

Subscribe to my newsletter

Get notified when I publish new articles and updates.