logo

Imad Attif

use cache Explained: Next.js Caching After the App Router

Imad Attif, Sr. Frontend Engineer

16 min read

Jul 29, 2026

For years, the honest answer to "how does Next.js caching work?" was "nobody's completely sure." The App Router shipped with four overlapping cache layers, fetch calls that cached themselves unless you told them not to, and behavior that changed between versions. Entire blog posts existed just to diagram it.

use cache is the reset. Along with Cache Components in Next.js 16, it replaces the old implicit machinery with one explicit model: nothing is cached unless you say so, and you say so with a directive. One keyword to cache, one function to control lifetime (cacheLife), one function to label entries (cacheTag), and two to invalidate them (updateTag, revalidateTag).

This post explains the whole model from scratch: how to enable it, where the directive goes, how cache keys are built, how long entries live, how invalidation works, and the pitfalls that will actually bite you. All code is verified against the current Next.js 16 docs.

The short version:

  • Enable cacheComponents: true in next.config.ts. Everything is now dynamic by default.
  • Add 'use cache' to a file, component, or function to cache its output.
  • Control how long it lives with cacheLife('hours') and similar profiles.
  • Label entries with cacheTag('posts'), then invalidate with updateTag('posts') after a mutation.
  • Keep cookies(), headers(), and searchParams outside cached code. Pass their values in as arguments.

Why did Next.js caching need a reset?

The App Router's original model cached aggressively and implicitly. fetch responses were cached by default. Routes were statically rendered unless something opted them out. The client router kept its own cache with its own rules. Developers spent their time discovering what was cached rather than deciding what should be.

Next.js 15 flipped the fetch default to uncached, which helped, but the deeper problem remained: caching decisions lived in scattered config (revalidate exports, fetch options, route segment settings) rather than in the code they affected.

Cache Components, stable in Next.js 16, is the redesign. The philosophy inverts:

  • Old model: everything is cached until you opt out. Caching is implicit and ambient.
  • New model: everything is dynamic until you opt in. Caching is explicit and local. You can look at any function and see whether it's cached, for how long, and under what tags, because it's written right there.

One config flag replaces the older experimental flags (ppr, useCache, dynamicIO):

next.config.ts

1import type { NextConfig } from 'next'2
3const nextConfig: NextConfig = {4  cacheComponents: true,5}6
7export default nextConfig

With that enabled, Next.js also turns on Partial Prerendering as the default rendering behavior: every route gets a prerendered static HTML shell that's served instantly, and the dynamic parts stream in behind <Suspense> boundaries. use cache is how you decide what goes into that shell.

What does the use cache directive do?

'use cache' marks a scope as cacheable. It works at three levels, and the placement decides how much gets cached.

File level: everything exported from the file is cached. All exports must be async functions.

app/blog/page.tsx

1'use cache'2
3export default async function BlogPage() {4  const posts = await getPosts()5  return <PostList posts={posts} />6}

Component level: one component's rendered output is cached, keyed by its props.

1export async function PostList({ category }: { category: string }) {2  'use cache'3  const posts = await fetchPosts(category)4  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>5}

Function level: any async function's return value is cached. This is the replacement for unstable_cache and for most hand-rolled memoization.

1export async function getExchangeRates() {2  'use cache'3  const res = await fetch('https://api.example.com/rates')4  return res.json()5}

The mental model: 'use cache' draws a boundary around some work. The first call with a given set of inputs does the work and stores the result. Later calls with the same inputs skip the work entirely.

How are cache keys built?

You never construct a key yourself. Next.js derives it from:

  1. The build ID. A new deploy invalidates everything, so stale code never serves stale data shapes.
  2. The function's identity. A hash of where the function lives and its signature.
  3. The serialized arguments. Props for a component, arguments for a function.
  4. Closed-over variables. If the cached function reads a variable from an enclosing scope, that value is captured and becomes part of the key too.

That last one deserves a look, because it's what makes the directive safe to use inside components:

1async function Component({ userId }: { userId: string }) {2  const getData = async (filter: string) => {3    'use cache'4    // The key includes BOTH userId (captured from the closure)5    // and filter (a regular argument)6    return fetch(`/api/users/${userId}/data?filter=${filter}`)7  }8
9  return getData('active')10}

Different users get different cache entries automatically. You don't need to remember to thread userId into some key string; the closure capture does it for you.

The serialization rule

Because inputs become cache keys and outputs get stored, both must be serializable. Primitives, plain objects, arrays, Dates, Maps, and Sets are fine as arguments. Return values can additionally include JSX. Class instances, functions, and Symbols are not allowed.

There's one important escape hatch: pass-through values. A cached component can accept non-serializable things like children or a Server Action, as long as it doesn't read them, only places them in its output:

1async function CachedShell({ children }: { children: ReactNode }) {2  'use cache'3  // Never introspect children. Just position it.4  return (5    <div className="layout">6      <header>My cached header</header>7      {children}8    </div>9  )10}11
12// The shell is cached; whatever you nest inside stays dynamic.13export default function Page() {14  return (15    <CachedShell>16      <PersonalizedFeed />17    </CachedShell>18  )19}

This is the single most useful composition pattern in the whole model: a cached frame around a dynamic center. It's how you get a fast static shell without giving up personalization.

How long does a cache entry live?

Every cached scope has a lifetime with three dials:

  • stale: how long the client's router can reuse its copy without asking the server. The client enforces a minimum of 30 seconds so prefetched links don't expire before they're clicked.
  • revalidate: how often the server refreshes the entry in the background. Requests during a refresh still get the cached version instantly (the same idea as ISR).
  • expire: the hard deadline. If an entry sits unused past this, the next visitor waits for fresh data instead of seeing something ancient.

If you say nothing, the default profile applies: 5 minutes stale, 15 minutes revalidate, never expires by time. To choose deliberately, call cacheLife inside the cached scope with a named profile:

1import { cacheLife } from 'next/cache'2
3export default async function BlogPage() {4  'use cache'5  cacheLife('days')6
7  const posts = await getBlogPosts()8  return <PostList posts={posts} />9}

The built-in profiles, from most to least volatile:

  • seconds: real-time data like stock prices. Revalidates every second, expires after a minute.
  • minutes: social feeds, news. Revalidates every minute, expires after an hour.
  • hours: inventory, weather. Revalidates hourly, expires after a day.
  • days: blog posts, articles. Revalidates daily, expires after a week.
  • weeks: podcasts, newsletters. Revalidates weekly, expires after 30 days.
  • max: legal pages, archives. Revalidates every 30 days, expires after a year.

You can define your own profiles in next.config.ts (for example an editorial profile shared across the site), or pass an inline object for one-off cases, which is also how you use a revalidation time that comes from your CMS:

1async function getPost(slug: string) {2  'use cache'3  const post = await fetchPost(slug)4
5  cacheLife({6    // Value comes from the CMS document itself7    revalidate: post.revalidateSeconds ?? 3600,8  })9
10  return post11}

One behavior worth knowing: entries with very short lifetimes (seconds, or any profile with expire under 5 minutes) are excluded from prerendering and become dynamic holes in the static shell. That's by design. It's what lets one page mix a prerendered frame with request-time data.

How do you invalidate the cache?

Time-based revalidation is the fallback. The real tool is tags: label entries when you cache them, invalidate by label when data changes.

Tag inside the cached scope with cacheTag:

1import { cacheTag } from 'next/cache'2
3async function getPosts() {4  'use cache'5  cacheTag('posts')6  return db.post.findMany()7}8
9async function getPost(slug: string) {10  'use cache'11  cacheTag('posts', `post-${slug}`)12  return db.post.findUnique({ where: { slug } })13}

hen invalidate after a mutation. Here Next.js 16 gives you two functions, and the difference matters:

updateTag is for Server Actions, when the user just changed something and must see the result immediately. It expires the tag right away, and the next request waits for fresh data. This is the read-your-own-writes tool:

1'use server'2
3import { updateTag } from 'next/cache'4import { redirect } from 'next/navigation'5
6export async function createPost(formData: FormData) {7  const post = await db.post.create({8    data: {9      title: formData.get('title'),10      content: formData.get('content'),11    },12  })13
14  updateTag('posts')          // every list of posts15  updateTag(`post-${post.id}`) // this post's detail page16
17  // The user lands on fresh data, never their own stale cache18  redirect(`/posts/${post.id}`)19}

revalidateTag is for everywhere else: Route Handlers, webhooks, CMS-triggered rebuilds. Called with the recommended 'max' profile, it serves the cached version while refreshing in the background (stale-while-revalidate), which is what you want when the "user" is a webhook and nobody is waiting to see their own edit:

app/api/revalidate/route.ts

1import { revalidateTag } from 'next/cache'2
3export async function POST(request: Request) {4  const { tag } = await request.json()5  revalidateTag(tag, 'max')6  return Response.json({ revalidated: true })7}

The rule of thumb: user just wrote something, use updateTag in the action. Machine told you something changed, use revalidateTag in the handler. Calling updateTag outside a Server Action throws.

Both functions also clear the client router's cache immediately, bypassing any stale time, so the invalidation actually reaches the screen.

What can't go inside a cached scope?

Cached functions run in an isolated world, and the constraints all follow from one idea: the output must be reproducible from the key alone.

Request APIs are banned inside. cookies(), headers(), and searchParams can't be read within 'use cache'. If they could, two users with different cookies would collide on the same cache entry. The fix is always the same shape: read outside, pass in as an argument.

1// ❌ Throws: request API inside a cached scope2async function getRecommendations() {3  'use cache'4  const region = (await cookies()).get('region')?.value5  return fetchRecommendations(region)6}7
8// ✅ Read outside, pass the value in.9// The region becomes part of the cache key automatically.10async function getRecommendations(region: string) {11  'use cache'12  return fetchRecommendations(region)13}14
15export default async function Page() {16  const region = (await cookies()).get('region')?.value ?? 'us'17  return <Recommendations data={await getRecommendations(region)} />18}

Runtime cache storage depends on where you host. By default, entries live in server memory. On serverless platforms, instances come and go, so runtime entries often don't survive between requests (build-time caching works regardless). Self-hosted Node servers keep entries warm across requests. If in-memory isn't enough, two directive variants exist: 'use cache: remote' lets the platform back the cache with a real store like Redis, and 'use cache: private' covers the rare cases where runtime request data genuinely can't be refactored out.

Draft Mode bypasses everything. When Draft Mode is on, cached scopes re-execute on every request and nothing is stored. Editors always preview fresh content without any code changes.

Pitfalls that will actually bite you

The build hang. If a build times out after 50 seconds with "Filling a cache during prerender timed out," you've smuggled runtime data into a cached scope indirectly: usually by passing an unresolved Promise as a prop, or stashing one in a shared Map. The cached function is waiting at build time for data that only exists at request time. Await the dynamic data outside, pass plain values in.

Nested caches and silent lifetime propagation. When a cached scope with no explicit cacheLife contains a shorter-lived cache, the outer lifetime shrinks to match. Worse, if the inner cache is short-lived enough to be a dynamic hole (like seconds), Next.js throws a prerender error rather than silently making your whole page short-lived, and the offending inner cache might be in a third-party package. The fix, and the general best practice: always set an explicit cacheLife on scopes that contain other cached code. Explicit lifetimes are also just easier to reason about in review.

Expecting fetch to cache itself. With Cache Components on, it doesn't. An uncached await fetch() in a page makes that part of the page dynamic, and with 16.3's Instant Insights it surfaces as an error asking you to choose: stream it behind Suspense, cache it with 'use cache', or explicitly allow blocking. The choice is yours now, which is the whole point.

Debugging blind. Two tools: NEXT_PRIVATE_DEBUG_CACHE=1 turns on verbose cache logging in dev and production, and console logs from inside cached functions show up in dev prefixed with Cache, so you can literally watch which executions are real and which are replays.

Putting it together: a realistic page

Here's the model applied to a blog post page with a personalized header:

app/posts/[slug]/page.tsx

1import { Suspense } from 'react'2import { cacheLife, cacheTag } from 'next/cache'3import { cookies } from 'next/headers'4
5// Cached: the post content. Tagged for invalidation,6// refreshed daily as a fallback.7async function Post({ slug }: { slug: string }) {8  'use cache'9  cacheLife('days')10  cacheTag(`post-${slug}`)11
12  const post = await db.post.findUnique({ where: { slug } })13  return <article>{post.content}</article>14}15
16// Dynamic: depends on the request, so it stays outside17// any cached scope and streams in behind Suspense.18async function Greeting() {19  const name = (await cookies()).get('name')?.value20  return <p>Welcome back{name ? `, ${name}` : ''}</p>21}22
23export default async function Page({24  params,25}: PageProps<'/posts/[slug]'>) {26  const { slug } = await params27  return (28    <main>29      <Suspense fallback={<p>Welcome</p>}>30        <Greeting />31      </Suspense>32      <Post slug={slug} />33    </main>34  )35}

The post is in the static shell and served instantly. The greeting streams in per request. When an editor updates the post, a Server Action calls updateTag(post-${slug}) and the change is live on the next request. Every caching decision is visible in the file where it applies. That's the after picture.

FAQ

Is use cache stable? Yes, as part of Cache Components in Next.js 16, enabled by the single cacheComponents: true flag. It began as an experimental directive in Next.js 15 behind experimental.useCache; those older flags (ppr, useCache, dynamicIO) are gone, folded into the one option. Static export is the notable unsupported deployment target.

What's the difference between use cache and React's cache()? React.cache deduplicates calls within a single render pass and stores nothing between requests. 'use cache' persists results across requests and users. They also don't share scope: a React.cache value set outside a 'use cache' boundary is invisible inside it, so pass data in as arguments instead.

What's the difference between updateTag and revalidateTag? updateTag works only in Server Actions, expires the tag immediately, and makes the next request wait for fresh data, so users always see their own writes. revalidateTag also works in Route Handlers, and with the 'max' profile serves stale content while refreshing in the background. Actions where a user awaits their change: updateTag. Webhooks and API-triggered invalidation: revalidateTag.

Does fetch still cache automatically? No. With Cache Components, data fetching is dynamic by default. You cache by wrapping the call (or its component) in 'use cache'. The next: { tags } option on fetch still exists and works with tag invalidation, but the caching decision itself is now always explicit.

Do cached components need Suspense? Not the cached ones; they're in the static shell. It's the dynamic parts (anything reading cookies, headers, or uncached data) that need a <Suspense> boundary so the shell can render instantly while they stream in. If a short-lived cache becomes a dynamic hole, wrap that in Suspense too.

Can I use cookies() inside use cache? No, and this is the most common error when adopting it. Read cookies() or headers() outside the cached scope and pass the values you need as arguments. They become part of the cache key automatically. For rare cases where that refactor is impossible, 'use cache: private' exists.

Summary

  • Cache Components inverts the old model: dynamic by default, cached by choice, with the choice written in the code it affects.
  • 'use cache' works at file, component, and function level. Keys are derived from the build, the function, its arguments, and captured closure variables.
  • Lifetimes have three dials (stale, revalidate, expire) set through cacheLife profiles. Set them explicitly, especially on scopes that contain other caches.
  • Tag with cacheTag, invalidate with updateTag (Server Actions, read-your-own-writes) or revalidateTag (handlers and webhooks, stale-while-revalidate).
  • Keep request APIs out of cached scopes; pass values as arguments. Watch for the build-hang and nested-lifetime pitfalls.
  • The payoff is a static shell served instantly with dynamic content streaming in, and a caching story you can finally read straight off the page.

After years of caching-by-archaeology, Next.js caching is now something you can hold in your head. One directive, three dials, two invalidation calls. That's the whole model.