Next.js use cache Explained: Cache Components, cacheLife and cacheTag in Next.js 16.4
Next.js use cache explained with tested Next.js 16.4 code: how Cache Components work, cacheLife profiles, cacheTag, updateTag vs revalidateTag, use cache private and remote, self-hosting and migrating an existing app.

On this page
Next.js use cache is a directive that marks an async function, component or whole file as cacheable, so its output is stored and reused for matching inputs. It is the core of Cache Components, the caching model that Next.js 16.4 now turns on by default in create-next-app. Add 'use cache', set a lifetime with cacheLife, label it with cacheTag, and invalidate it with updateTag or revalidateTag.
This guide is written against Next.js 16.4.2 and React 19.3, the current releases. I scaffolded a fresh app with create-next-app@16.4.2 (its generated next.config.ts already contains cacheComponents: true and partialPrefetching: true), wrote every example below, then type-checked, linted, built and ran it with next start. The build output, headers and timestamps shown are the real ones I got back. Framework behavior links to the official Next.js docs.
Security note: the October 14, 2026 Next.js security update fixed a critical remote code execution bug in
use cachefunctions that return multiple typed arrays. If you use Cache Components, run Next.js 16.4.1 or later before you ship anything from this post.
What does "use cache" do in Next.js?#
'use cache' tells Next.js that a function's return value depends only on its inputs, so it can be stored and reused. The first call runs the function and saves the output; later calls with the same arguments get the saved output until the entry revalidates. Cached output can also be baked into the page's prerendered static shell.
The official use cache reference lists what goes into a cache key:
- Build ID: every deploy gets new keys, so caches do not survive a deploy.
- Function ID: a secure hash of where the function lives and its signature.
- Serializable arguments: function arguments or component props.
- Closed-over variables: values captured from an outer scope become part of the key.
That makes it different from the old App Router model, where fetch calls and route segments were cached implicitly. With Cache Components, nothing is cached unless you say so, and anything you do cache is explicit at the call site.
Quick definitions#
TL;DR: Next.js use cache in five lines#
Is Cache Components enabled by default in Next.js 16.4?#
Yes, for new apps. Since Next.js 16.4, released on October 6, 2026, every app created with create-next-app has Cache Components enabled, and the Next.js team says it will become the default for all apps in Next.js 17. Existing apps keep the old model until you add cacheComponents: true.
This is what create-next-app@16.4.2 generated for me, unedited apart from the Tailwind loader lines:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
}
export default nextConfignext build confirms it at the top of the log:
✓ Running next.config.ts took 21ms
- Cache Components enabled
- Partial Prefetching enabledHow do you use "use cache" in Next.js? (step by step)#
Here is the full working pattern from my test app: a product list that every visitor shares, a greeting that reads a cookie, and a form that adds a product.
Step 1: cache the data function#
Put the directive at the top of the async function body, then set an explicit lifetime and a tag:
// lib/products.ts
import { cacheLife, cacheTag } from 'next/cache'
import { queryProducts } from './db'
// Shared, cached data function: one entry for every visitor.
export async function getProducts() {
'use cache'
cacheLife('hours')
cacheTag('products')
const products = await queryProducts()
return { products, cachedAt: new Date().toISOString() }
}cachedAt is there only to prove caching in the tests below. Because new Date() runs inside the cached scope, its value is stored with the entry, and the route still prerenders.
Step 2: render cached and request-time parts on one page#
// app/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { getProducts } from '@/lib/products'
import { addProduct } from './actions'
export default function Home() {
return (
<main>
<h1>Store</h1>
{/* Request-time hole: reads a cookie, so it streams in */}
<Suspense fallback={<p>Loading greeting...</p>}>
<Greeting />
</Suspense>
{/* Cached: part of the static shell */}
<ProductList />
<form action={addProduct}>
<input name="name" placeholder="Name" />
<input name="price" type="number" placeholder="Price" />
<button>Add</button>
</form>
</main>
)
}
async function Greeting() {
const name = (await cookies()).get('name')?.value ?? 'guest'
return <p>Hi, {name}</p>
}
async function ProductList() {
const { products, cachedAt } = await getProducts()
return (
<section>
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}: ${p.price}</li>
))}
</ul>
<p>cached at {cachedAt}</p>
</section>
)
}Step 3: invalidate from a Server Action with updateTag#
// app/actions.ts
'use server'
import { updateTag } from 'next/cache'
import { insertProduct } from '@/lib/db'
export async function addProduct(formData: FormData) {
const name = String(formData.get('name') ?? '').trim()
const price = Number(formData.get('price'))
if (!name || !Number.isFinite(price)) return
await insertProduct({ name, price })
// Expire the tag now: the next read waits for fresh data
// (read-your-own-writes for the user who submitted the form).
updateTag('products')
}In a real app, authenticate and authorize inside the action first. My Server Actions vs Route Handlers guide covers why every action is a public endpoint.
Step 4: invalidate from a webhook with revalidateTag#
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
// Called by a CMS or admin webhook after products change.
export async function POST(request: Request) {
if (request.headers.get('authorization') !== `Bearer ${process.env.REVALIDATE_SECRET}`) {
return Response.json({ error: 'unauthorized' }, { status: 401 })
}
// Stale-while-revalidate: visitors keep getting the old list
// while the next request refreshes it in the background.
revalidateTag('products', 'max')
return Response.json({ revalidated: true })
}Step 5: build and read the route table#
Route (app) Revalidate Expire
┌ ◐ / 1h 1d
├ ○ /_not-found
├ ◐ /account
├ ○ /api/products 1h 1d
└ ƒ /api/revalidate
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
ƒ (Dynamic) server-rendered on demandThe home page is ◐ Partial Prerender: the product list is in the static shell, and the cookie greeting streams in. The 1h / 1d columns come straight from the hours profile. The webhook route is dynamic, as it should be.

Does it actually cache? What I measured with next start#
These are the real responses from next start on the test app.
Two requests, one entry. A GET Route Handler that calls getProducts() returned the same timestamp twice. It was filled during next build and reused:
$ curl -s localhost:3141/api/products
{"count":2,"cachedAt":"2026-10-17T13:01:48.233Z"}
$ curl -s localhost:3141/api/products
{"count":2,"cachedAt":"2026-10-17T13:01:48.233Z"}Shared cache, personal hole. Sending cookie: name=Waseem changed the greeting to Hi, Waseem while the product list kept the same cached at value. The response headers show how Next.js treats the page:
x-nextjs-stale-time: 300
x-nextjs-prerender: 1
x-nextjs-postponed: 1x-nextjs-stale-time: 300 is the 5-minute client stale value from the hours profile, and x-nextjs-postponed: 1 means part of the page was deferred to request time.
revalidateTag is stale-while-revalidate. Without the bearer token the webhook returned 401. With it, the first request after revalidation still got the old entry, and the next one got a fresh one:
$ curl -s -X POST -H 'authorization: Bearer s3cret' localhost:3141/api/revalidate
{"revalidated":true}
$ curl -s localhost:3141/api/products
{"count":2,"cachedAt":"2026-10-17T13:01:48.233Z"} # stale, refresh starts
$ curl -s localhost:3141/api/products
{"count":2,"cachedAt":"2026-10-17T13:02:01.283Z"} # freshupdateTag is read-your-own-writes. In a headless Chrome test, submitting the form with "Webcam, 59" re-rendered the list with three items and a new cached at time in the same navigation. A second browser that opened the page afterwards saw the same three items and the same new timestamp, so the shared entry was replaced, not forked per user.
What are the cacheLife profiles?#
cacheLife sets three values: stale (how long the browser may reuse it without asking), revalidate (after which the server refreshes it in the background) and expire (after which a request must wait for fresh data). These are the built-in presets from the cacheLife reference:
Three rules worth remembering:
- Always set
cacheLifeexplicitly. Without it thedefaultprofile applies, and nesting a short-lived cache inside a scope with no explicitcacheLifefails the build during prerendering. - The browser keeps cached output for at least 30 seconds. The client router enforces a minimum 30-second stale time regardless of your profile.
- You can define custom profiles in
next.config.tsundercacheLife, or pass an inline object such ascacheLife({ stale: 60, revalidate: 600, expire: 3600 }).
updateTag vs revalidateTag: which should you use?#
Use updateTag inside a Server Action when the user who made a change must see it on the very next render. Use revalidateTag(tag, 'max') from a Route Handler or webhook when a short window of stale data is fine. updateTag blocks the next read until fresh data loads; revalidateTag serves stale content while it refreshes.
The revalidateTag reference recommends the 'max' profile and marks the single-argument form as deprecated; it behaves like { expire: 0 }, a blocking cache miss. Tags are case-sensitive and capped at 256 characters, and a longer tag is silently never assigned. Revalidation is triggered by the next request, not by the call itself, so pages refresh as they are visited.

use cache vs use cache: private vs use cache: remote#
Next.js has three caching directives. Plain 'use cache' covers most apps. The other two solve specific problems, and the use cache: remote reference compares them:
When should you use "use cache: private"?#
Use it only when a function must read cookies or headers itself and you cannot refactor it to take those values as arguments, or when compliance rules forbid storing request data in a server cache. It runs at request time, is excluded from the static shell and is cached only in the browser. This is the version from my test app:
// app/account/page.tsx (excerpt)
import { cookies } from 'next/headers'
import { cacheLife } from 'next/cache'
// Reads cookies directly. Cached per browser only, never in a shared server cache.
async function getPreferences() {
'use cache: private'
cacheLife({ stale: 60 })
const currency = (await cookies()).get('currency')?.value ?? 'USD'
return { currency }
}With a currency=PKR cookie the page rendered Currency: PKR, and the route still built as ◐ because the component sits inside <Suspense>.
When does "use cache: remote" make sense?#
When request-time content gets low hit rates from per-instance memory: serverless functions, many replicas, or an expensive upstream such as a slow CMS. It stores entries in a shared handler like Redis. Skip it when operations already take under about 50 ms, when keys are mostly unique per request, or when data changes every few seconds; the docs list those as cases where remote caching does not pay off.
Does "use cache" work when self-hosting with Docker or Coolify?#
Yes. The official platform table lists both the Node.js server and Docker as supported, and static export as not supported. When self-hosted, the default in-memory cache persists across requests in the same process, which is exactly what the next start tests above show. Size it with cacheMaxMemorySize.
Two things change once you run more than one container:
- Each replica has its own memory cache. Two instances can serve different
cachedAtvalues until both refresh. Configure a sharedcacheHandlersentry (for example Redis) if that matters. - Every deploy starts cold. The cache key includes the build ID, so
use cacheentries do not carry over. For data that must survive deploys, the docs still point tounstable_cacheor thefetchcache.
If you are deploying this way, my self-hosting Next.js with Coolify guide and production Next.js Dockerfile cover the container side.
How do you migrate an existing app to Cache Components?#
Enable cacheComponents: true, then replace each route segment config the build complains about. The migration guide says routes that still export dynamic, revalidate or fetchCache will error once the flag is on. Most of the work is mechanical:
For a large app, adopt it incrementally:
- Turn on the flag and migrate the segment configs above.
- Opt out routes that are not ready with
export const instant = false, or run the codemod across the app:npx @next/codemod@canary cache-components-instant-false ./app. - Fix synchronous IO such as
new Date(),Math.random()andcrypto.randomUUID()in the prerendered shell;instant = falsedoes not clear those errors. Move the call into a cached scope, a Client Component, or behindconnection()inside<Suspense>. - Remove
instant = falseone route at a time and resolve the dev overlay insights.
If you use a coding agent, Next.js ships a skill for this: npx skills add vercel/next.js --skill next-cache-components-adoption, then ask the agent to adopt Cache Components with it.
What can't you do inside "use cache"?#
Cached functions run in an isolated environment, and four limits catch most people:
- No request APIs.
cookies(),headers()andsearchParamsare not allowed, even in a helper the cached function calls. On dynamic routes this can passnext buildand fail undernext startwith thenext-request-in-use-cacheerror. - Serializable inputs and outputs only. Arguments follow Server Component serialization, return values follow Client Component serialization. Class instances, symbols,
URLobjects and functions (except as pass-through) are rejected. React.cachedoes not cross the boundary. Values stored withReact.cacheoutside a cached scope are not visible inside it. Pass data as arguments.- Promises of runtime data hang the build. Passing a
cookies()promise or uncached data into a cached function makes prerendering wait, and the build times out after 50 seconds.
For debugging, run NEXT_PRIVATE_DEBUG_CACHE=1 npm run start for verbose cache logs. In development, logs from cached functions are replayed with a Cache prefix.
Common "use cache" mistakes#
Related: Next.js 16.4.1 patched a critical RCE in use cache functions that return typed arrays; see the Next.js security update October 2026. To let an AI agent do the migration for you, install the official next-cache-components-adoption skill as shown in Agent Skills: how to write a SKILL.md.
Related: I reused this demo app to benchmark the TypeScript 7 upgrade: the next build type-check step dropped from 1,368 ms to 231 ms.
Key takeaways#
'use cache'caches an async function, component or file by its inputs; with Cache Components nothing else is cached implicitly.- New Next.js 16.4 apps have Cache Components and Partial Prefetching on by default, and it becomes the default for all apps in Next.js 17.
- Pair every cached scope with
cacheLifeandcacheTag, then invalidate withupdateTagin Server Actions orrevalidateTag(tag, 'max')elsewhere. - Request APIs stay outside cached scopes; pass their values in as arguments, or reach for
'use cache: private'when you truly cannot. - Self-hosted
next startkeeps entries in memory across requests, but not across deploys or between replicas.
Questions
Share


