DEV Community

Cover image for Next.js use cache Explained: Cache Components, cacheLife and cacheTag in Next.js 16.4
Waseem Anjum
Waseem Anjum

Posted on Originally published at fabwaseem.com

Next.js use cache Explained: Cache Components, cacheLife and cacheTag in Next.js 16.4

Cross-posted from my blog. The original, with any updates, lives on fabwaseem.com.

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 cache functions 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:

  1. Build ID: every deploy gets new keys, so caches do not survive a deploy.
  2. Function ID: a secure hash of where the function lives and its signature.
  3. Serializable arguments: function arguments or component props.
  4. 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

Term What it means
Cache Components The Next.js 16 programming model behind cacheComponents: true: explicit use cache, Partial Prerendering and Suspense streaming (docs)
'use cache' Directive that caches a function, component or file's exports, shared across users
cacheLife Sets how long an entry is fresh (stale, revalidate, expire) (docs)
cacheTag Labels an entry so you can invalidate it on demand (docs)
updateTag Expires a tag immediately from a Server Action; the next read waits for fresh data
revalidateTag Marks a tag stale from a Server Function or Route Handler; serves stale while it refreshes
Static shell The prerendered HTML of a route, including cached output and Suspense fallbacks

TL;DR: Next.js use cache in five lines

You want to... Do this
Cache a database query or API call for everyone 'use cache' + cacheLife('hours') + cacheTag('x') in the data function
Show a user's change right after they submit a form updateTag('x') inside the Server Action
Refresh after a CMS or Stripe webhook revalidateTag('x', 'max') in a Route Handler
Read cookies or headers Read them outside the cached scope and pass values as arguments
Cache something that must read cookies inside 'use cache: private' (per browser only)
Share a cache across many server instances 'use cache: remote' with a cache handler

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 nextConfig
Enter fullscreen mode Exit fullscreen mode

next build confirms it at the top of the log:

✓ Running next.config.ts took 21ms
- Cache Components enabled
- Partial Prefetching enabled
Enter fullscreen mode Exit fullscreen mode

How 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() }
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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')
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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 demand
Enter fullscreen mode Exit fullscreen mode

The 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.

How Cache Components render one page: static markup and use cache output go into the static shell, while a Suspense boundary that reads cookies streams at request time

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"}
Enter fullscreen mode Exit fullscreen mode

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: 1
Enter fullscreen mode Exit fullscreen mode

x-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"}   # fresh
Enter fullscreen mode Exit fullscreen mode

updateTag 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:

Profile Use case stale revalidate expire
default Standard content 5 minutes 15 minutes never
seconds Real-time data 30 seconds 1 second 1 minute
minutes Frequently updated content 5 minutes 1 minute 1 hour
hours Updated several times a day 5 minutes 1 hour 1 day
days Updated daily 5 minutes 1 day 1 week
weeks Updated weekly 5 minutes 1 week 30 days
max Rarely changes 5 minutes 30 days 1 year

Three rules worth remembering:

  • Always set cacheLife explicitly. Without it the default profile applies, and nesting a short-lived cache inside a scope with no explicit cacheLife fails 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.ts under cacheLife, or pass an inline object such as cacheLife({ 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.

updateTag(tag) revalidateTag(tag, 'max')
Where it runs Server Actions only Server Functions and Route Handlers
Next read Waits for fresh data Gets stale data, triggers a refresh
Best for Forms, settings, carts: read-your-own-writes CMS publish, Stripe and GitHub webhooks, admin jobs
In a Route Handler Throws Works

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.

Decision chart: use cache for shared data, use cache remote for shared data across many instances, use cache private for data that must read cookies; then updateTag for Server Actions, revalidateTag with max for webhooks, and cacheLife for time-based refresh

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:

Feature 'use cache' 'use cache: remote' 'use cache: private'
Server-side storage In memory or a cache handler Remote cache handler None
Shared between users Yes Yes No, per browser
Can call cookies() / headers() inside No, pass values as arguments No, pass values as arguments Yes
Extra cost None Storage and network None
Survives a deploy No No N/A

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 }
}
Enter fullscreen mode Exit fullscreen mode

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 cachedAt values until both refresh. Configure a shared cacheHandlers entry (for example Redis) if that matters.
  • Every deploy starts cold. The cache key includes the build ID, so use cache entries do not carry over. For data that must survive deploys, the docs still point to unstable_cache or the fetch cache.

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:

Before (old App Router) After (Cache Components)
export const revalidate = 3600 'use cache' + cacheLife('hours') around the data or UI
export const revalidate = 0 / force-dynamic Remove it; uncached data already runs at request time
export const fetchCache = 'force-cache' Wrap the data in 'use cache'
unstable_cache(fn, keys, { tags, revalidate }) Optional: 'use cache' + cacheTag + cacheLife; the old API keeps working
fetch(url, { next: { tags } }) cacheTag inside a 'use cache' function
revalidateTag('x') updateTag('x') in actions, revalidateTag('x', 'max') elsewhere

For a large app, adopt it incrementally:

  1. Turn on the flag and migrate the segment configs above.
  2. 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.
  3. Fix synchronous IO such as new Date(), Math.random() and crypto.randomUUID() in the prerendered shell; instant = false does not clear those errors. Move the call into a cached scope, a Client Component, or behind connection() inside <Suspense>.
  4. Remove instant = false one 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() and searchParams are not allowed, even in a helper the cached function calls. On dynamic routes this can pass next build and fail under next start with the next-request-in-use-cache error.
  • Serializable inputs and outputs only. Arguments follow Server Component serialization, return values follow Client Component serialization. Class instances, symbols, URL objects and functions (except as pass-through) are rejected.
  • React.cache does not cross the boundary. Values stored with React.cache outside 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

Mistake What happens Fix
Calling cookies() inside 'use cache' next-request-in-use-cache error Read it outside and pass the value in
Caching per-user data with plain 'use cache' Users can see each other's data if the key misses the user ID Add the user ID as an argument, or use 'use cache: private'
No cacheLife default profile applies; nested short caches can fail the build Set a profile in every cached scope
updateTag in a Route Handler Throws at runtime Use revalidateTag(tag, 'max')
revalidateTag('x') with one argument Deprecated, blocking miss Pass 'max' or migrate to updateTag
Expecting caches to survive deploys Cold cache after each deploy Accept it, or use unstable_cache / fetch cache for that data
Several replicas, default handler Replicas disagree for a while Shared cacheHandlers or 'use cache: remote'
Running Next.js below 16.4.1 Exposed to the October 2026 critical advisories Upgrade now

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.

Related: my React View Transitions guide uses Cache Components with connection() so a Suspense fallback streams in and animates its reveal.

Related: for a side-by-side with a non-Next.js framework, see TanStack Start vs Next.js, where I benchmarked the same app on both for server throughput, client JS, hydration and build time.

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 cacheLife and cacheTag, then invalidate with updateTag in Server Actions or revalidateTag(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 start keeps entries in memory across requests, but not across deploys or between replicas.

FAQ

What does 'use cache' do in Next.js?

'use cache' marks an async function, component or file's exports as cacheable. Next.js runs it once for a given set of serializable inputs, stores the output, and reuses it until the entry revalidates through cacheLife or a cacheTag invalidation. Cached output can also be included in the route's prerendered static shell.

Is Cache Components enabled by default in Next.js 16.4?

Yes for new apps. Since Next.js 16.4, create-next-app generates a next.config.ts with cacheComponents: true and partialPrefetching: true. Existing apps keep the previous model until you enable the flag, and the Next.js team plans to make Cache Components the default for all apps in Next.js 17.

What is the difference between updateTag and revalidateTag?

updateTag runs only in Server Actions and expires a tag immediately, so the next read waits for fresh data and the user sees their own change. revalidateTag runs in Server Functions and Route Handlers; with the recommended 'max' profile it marks the tag stale and serves stale content while the next request refreshes it.

Can I use cookies() inside 'use cache'?

No. cookies(), headers() and searchParams are not allowed inside a 'use cache' scope or any helper it calls, and doing so throws the next-request-in-use-cache error. Read the value outside the cached function and pass it in as an argument. If you cannot refactor, 'use cache: private' allows request APIs but caches only in the browser.

Does 'use cache' replace unstable_cache?

For most code, yes, but you do not have to migrate. unstable_cache keeps working with Cache Components. One difference matters: unstable_cache and the fetch cache persist across deployments, while 'use cache' entries are keyed by build ID and start cold after every deploy.

Does 'use cache' work when self-hosting Next.js with Docker?

Yes. Node.js servers and Docker containers are supported; static export is not. Self-hosted, the default in-memory cache persists across requests in the same process, sized by cacheMaxMemorySize. With several replicas each has its own cache, so configure a shared cacheHandlers entry such as Redis if they must agree.


Originally published on fabwaseem.com.

Top comments (0)