Skip to content
Web

Next.js 16 caching explained: use cache, cacheLife and updateTag

In Next.js 16 nothing is cached until you write 'use cache'. How cacheLife's seven profiles, cacheTag and updateTag fit together, and where fetch stands now.

October 7, 2026 · 10 min read

A grid of twelve dark tiles with six lit amaranth, each lit tile circled by a progress ring of a different length; three threads run from the right column of tiles to one lit pill on the right, and the middle tile of that column glows brighter with a full ring

The Next.js 16 caching model is an opt-in system: nothing is cached until you mark a function, a component or a file with the 'use cache' directive, and every cached entry carries a lifetime set by cacheLife plus, optionally, tags you invalidate after a write. It replaces the implicit caches of the early App Router with one directive and a handful of functions. One flag turns it on, cacheComponents: true, and the same flag makes Partial Prerendering the default way a route renders.

This matters to any team on the App Router that has asked why a page showed old data, or why a page meant to be static rendered on every request. Both questions had the same cause: the framework decided what to cache, and the decision was hard to see. In Next.js 16 the decision sits in your code, next to the data it applies to. What follows is the model as documented for Next.js 16.4, released on 6 October 2026.

The 30-second version

  • Dynamic by default. With Cache Components on, every fetch and every database call runs at request time unless it sits inside a 'use cache' scope.
  • A lifetime per entry. cacheLife('hours'), or a profile of your own, sets three timers: how long the browser reuses the entry, when the server refreshes it in the background, and when it expires.
  • Invalidation by tag. cacheTag('posts') labels an entry. After a write, updateTag('posts') makes the next read wait for fresh data, and revalidateTag('posts', 'max') serves the old copy while a new one is built.
  • The static shell comes from the cache. Cached output with a long enough lifetime is prerendered into the route's shell. Short-lived entries become holes that fill at request time.

Why did Next.js change its caching model again?

Because the defaults surprised people. In Next.js 14, fetch used force-cache unless told otherwise, GET route handlers were cached, and the client router kept page segments in memory between navigations. A page could serve old data without a single line of code saying so. Next.js 15 flipped those defaults: fetch requests, GET handlers and client navigations stopped being cached unless you opted in.

That removed the surprise and left the tools scattered: a cache option on fetch, next.revalidate, the route segment configs dynamic, revalidate and fetchCache, and unstable_cache for anything that was not a fetch. Next.js 16, released in October 2025, gathered them into one directive. Once Cache Components is on, the official migration guide has you delete dynamic, revalidate and fetchCache from your routes and put the caching decision on the data instead.

How does 'use cache' work?

'use cache' marks an async function, an async component or a whole file as cacheable. The first call with a given set of inputs runs the body and stores the output. Every later call with the same inputs reuses it, inside one render and across requests, until the entry's lifetime runs out.

import { cacheLife, cacheTag } from 'next/cache'

export async function getProject(id: string) {
  'use cache'
  cacheLife('hours')
  cacheTag('projects', `project-${id}`)
  return db.project.findUnique({ where: { id } })
}

The cache key is built from four things: the build ID, a hash of the function's location and signature, its serialized arguments, and any variables it captures from the outer scope. Two consequences follow. Arguments and return values must be serializable, so no class instances and no open connections. And a new deploy starts with an empty cache, because the build ID is part of every key.

A cached scope cannot read cookies(), headers() or searchParams, and the rule follows the call stack: a helper that reads a cookie fails inside a cached function too. The docs warn that on a dynamic route this error can pass next build and only surface under next start. The pattern that works is to read request data outside and pass it in as an argument, which also makes it part of the key.

import { cookies } from 'next/headers'

export default async function Page() {
  const teamId = (await cookies()).get('team')?.value ?? ''
  const projects = await getProjects(teamId) // cached per teamId
  return <ProjectList projects={projects} />
}

How long does a cached entry live?

cacheLife sets three timers. stale is how long the browser reuses the entry without asking the server. revalidate is when the next request triggers a background refresh while still getting the cached copy. expire is when an entry nobody has requested is dropped, so the next request waits for fresh output. Next.js ships seven presets:

  • default: stale 5 minutes, revalidate 15 minutes, never expires. Applied when a scope calls no cacheLife.
  • seconds: stale 30 seconds, revalidate 1 second, expire 1 minute.
  • minutes: stale 5 minutes, revalidate 1 minute, expire 1 hour.
  • hours: stale 5 minutes, revalidate 1 hour, expire 1 day.
  • days: stale 5 minutes, revalidate 1 day, expire 1 week.
  • weeks: stale 5 minutes, revalidate 1 week, expire 30 days.
  • max: stale 5 minutes, revalidate 30 days, expire 1 year.

You can redefine any preset or add named profiles in next.config.ts. The docs recommend calling cacheLife in every cached scope, and we agree: an implicit default is the first thing nobody remembers six months later.

The lifetime also decides where the output can go. An entry with revalidate at 0 or expire under 5 minutes is left out of the prerender and becomes a dynamic hole. A stale under 30 seconds is left out too, because a prefetch would expire before the user clicks. Of the presets, only seconds crosses those lines. On the client, the router keeps any entry for at least 30 seconds, whatever you configure.

How do you invalidate after a write?

Time-based expiry covers content that drifts. Data a user just changed needs an on-demand path, and Next.js 16 gives you three, each with a different promise:

  • updateTag(tag) expires every entry with that tag at once, and the next read waits for fresh data. It works only inside Server Actions. Use it for forms: the user renames a project and sees the new name, not the old one.
  • revalidateTag(tag, 'max') marks the entries stale. The next request still gets the old copy while a fresh one is built in the background. It works in Server Actions and Route Handlers. Use it for webhooks and CMS publishes, where a few seconds of old content cost nothing. The second argument is now required; the one-argument form is deprecated.
  • refresh() touches no cache entry. It tells the client router to fetch the current page again from inside a Server Action, for data that was never cached.
'use server'
import { updateTag } from 'next/cache'

export async function renameProject(id: string, name: string) {
  await db.project.update({ where: { id }, data: { name } })
  updateTag(`project-${id}`)
}
import { revalidateTag } from 'next/cache'

export async function POST(request: Request) {
  const { slug } = await request.json()
  revalidateTag(`post-${slug}`, 'max')
  return Response.json({ ok: true })
}

Tags are where most of the design work goes. cacheTag accepts several values, so tag each entry with its collection (projects) and its record (project-42). A list invalidates on the first, a detail page on the second. Tags are case-sensitive and capped at 256 characters; a longer tag is never assigned, so invalidating it does nothing and raises no error.

Where does fetch fit now?

With Cache Components on, fetch is uncached unless it runs inside a 'use cache' scope. The migration guide moves cache: 'force-cache' into a cached function, next.revalidate into cacheLife, and next.tags into cacheTag. unstable_cache keeps working, so a large codebase can migrate one data function at a time.

One difference is easy to miss. The fetch cache and unstable_cache can outlive a deploy; 'use cache' entries never do, because the build ID is in the key. If you deploy ten times a day against a slow upstream API, every deploy starts cold. The docs point to the fetch cache for data that must persist across deploys.

Where is the cache stored at runtime?

By default, in memory: an LRU store inside the server process. How useful that is depends on where you host. On a long-running server, entries persist across requests and cacheMaxMemorySize caps them. On serverless, each request can land on a different instance, so runtime entries are often lost; build-time caching still feeds the static shell. We compared those trade-offs in self-hosting Next.js with Docker vs Vercel.

Two variants of the directive cover the gaps. 'use cache: remote' stores entries in a cache handler the platform provides, such as Redis or a KV store, and works after request data has been read. It costs a network round trip on every lookup and usually a platform fee. 'use cache: private' may read cookies and headers, and keeps its results only in the browser's memory, never on the server. The docs reserve it for compliance needs or code that cannot be refactored to pass request data as arguments.

Cache Components also requires the Node.js runtime. A route that still exports runtime = 'edge' has to move before you switch the flag on, which settles one question from our edge vs Node runtime comparison for any App Router project that adopts the model.

How does caching fit with Partial Prerendering?

They are one mechanism seen from two sides. At build time Next.js renders everything it can: static markup, plus every cached scope whose lifetime is long enough. That output becomes the shell served from the CDN. Uncached data and request-time APIs sit inside Suspense boundaries and stream into the same response. Choosing a cacheLife is choosing how much of the page ships instantly. The route-level side is in our guide to structuring routes for Partial Prerendering.

The rules we follow on a SaaS codebase

  1. Cache data functions, not pages. A 'use cache' at the top of a page file caches everything under it, including whatever someone adds next quarter. A cached getProject(id) stays as narrow as its name.
  2. Call cacheLife in every scope. Nested caches without an explicit lifetime inherit a short inner one; Next.js throws at prerender time when that happens, and a stated lifetime avoids the error in the first place.
  3. Tag by collection and by record. Two tags per entry cover a list and a detail page.
  4. updateTag in actions, revalidateTag(tag, 'max') in webhooks. The person who made the change sees it at once; everyone else gets it on their next request. We use the same split in our Server Actions patterns.
  5. Request data stays outside. Read the cookie in the page, pass the team ID into the cached function.
  6. Reach for 'use cache: remote' only when the origin cannot take the load. Each lookup becomes a network call, so it trades latency for fewer hits on the database.

When the model costs more than it saves

Turning on cacheComponents in an existing app is a migration. Everything becomes dynamic until you mark it, so a site that relied on the old defaults gets slower before it gets faster. Build-time validation flags each route that reads request data outside a Suspense boundary, and on a large App Router codebase clearing them is real work, done route by route. An app that is entirely per-user, with no shared data, gains little: the cache has nothing to share across requests. For a marketing site with content that changes weekly, the model fits cleanly: cacheLife('days') on the content functions, one tag per entry, and a webhook calling revalidateTag on publish.

Sources

Frequently asked questions

Related articles

Studio

Start a project.

We write about what we build. Tell us what you want to build.