CodeSapient
← All articles

Next.js Cache Components: A Practical Guide to use cache and Migration

How Next.js Cache Components work: use cache, cacheLife, cacheTag, updateTag vs revalidateTag, migrating from segment configs, and the mistakes to avoid, with real measurements from a Next.js 16.4 lab.

By CodeSapient Labs ·
Ink and watercolor sketch of a browser window showing a static shell, a cached use cache sidebar and a dashed dynamic hole that streams at request time, titled Next.js Cache Components
Ink and watercolor sketch titled Next.js Cache Components: a browser window whose header and footer are painted as the static shell, a green sidebar marked use cache with cacheLife hours, a cached product grid, and a dashed dynamic hole that streams at request time

Most App Router teams have lived through the same incident at least once. A product page shows yesterday's price because a fetch was cached by default. Someone adds export const dynamic = 'force-dynamic' to fix it, and now every request for that route hits the database, including the 90% of the page that never changes. Caching in Next.js used to be something you discovered after the fact.

Next.js Cache Components flips that model. Nothing is cached unless you say so with 'use cache', and you say it at the level of a function or component instead of a whole route. With Next.js 16.4 (released October 6, 2026) the Next.js team now recommends Cache Components for every app, new create-next-app projects have it enabled by default, and it is slated to become the default in Next.js 17. If you maintain a Next.js product, this is the migration to plan for now rather than under deadline.

This guide covers how the model works, how to choose lifetimes and invalidation, how to migrate an existing app, and the mistakes we see most often. The numbers in the lab section come from a small Next.js 16.4.3 app we built and measured ourselves.

What Cache Components actually change

Under the previous App Router model, caching was implicit and route-wide. A route was either static or dynamic, fetch had its own Data Cache, and segment configs like revalidate, dynamic and fetchCache changed behaviour for everything on the page.

With cacheComponents: true, three ideas replace that:

  • Dynamic by default. Data fetching runs at request time unless it sits inside a 'use cache' scope.
  • Explicit, composable caching. You mark a data function, a component, or a whole file with 'use cache', give it a lifetime with cacheLife and a label with cacheTag.
  • Partial Prerendering as the default. Next.js prerenders a static HTML shell from everything that is static or cached, then streams the request-time parts into <Suspense> boundaries in the same response.

The 16.4 announcement describes 'use cache' as "a component-level version of the Cache-Control HTTP header", which is a useful mental model: you are declaring how long a piece of output can be reused, not tuning a framework-wide cache.

Enabling it

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
}

export default nextConfig

Set partialPrefetching explicitly. Leaving it unset logs a warning, because the first release of Cache Components shipped without it, and the docs say both flags will be enabled everywhere in the next major version. Cache Components also requires the Node.js runtime, so any export const runtime in your segments has to go.

How use cache, cacheLife and cacheTag fit together

Here is the pattern most production pages end up with: a cached catalog query, a request-time greeting that reads a cookie, and a static layout around both.

import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife, cacheTag } from 'next/cache'

async function getProducts() {
  'use cache'
  cacheLife('hours')
  cacheTag('products')
  return db.products.findMany({ where: { active: true } })
}

export default function Page() {
  return (
    <main>
      <h1>Catalog</h1>
      <Suspense fallback={<p>Loading…</p>}>
        <Greeting />
      </Suspense>
      <ProductList />
    </main>
  )
}

async function Greeting() {
  const name = (await cookies()).get('name')?.value ?? 'guest'
  return <p>Hello, {name}</p>
}

async function ProductList() {
  const products = await getProducts()
  return <ul>{products.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
}

A few rules make this predictable:

  • Cache keys are automatic. The key includes the build ID, a hash of the function, and its serialized arguments. Variables captured from an outer scope become arguments too, so a cached function inside a component that closes over userId gets one entry per user.
  • Arguments and return values must be serializable. Plain objects, arrays, dates, Maps and Sets are fine. Class instances such as ORM model objects are not, which is the first thing that breaks when teams wrap an existing data layer.
  • No request-time APIs inside the scope. cookies(), headers() and searchParams fail inside 'use cache', and the restriction follows the call stack into helpers. Read them outside and pass the value in as an argument.
  • Always pair 'use cache' with cacheLife. Without it the default profile applies (5 minutes stale on the client, 15 minutes server revalidate, never expires by time), which is rarely what you meant.

Choosing a lifetime

cacheLife takes a built-in profile (seconds, minutes, hours, days, weeks, max) or an object with stale, revalidate and expire in seconds. Two details matter in practice. First, very short lifetimes (the seconds profile, revalidate: 0, or expire under five minutes) are treated as short-lived and left out of the prerendered shell, so they become dynamic holes. Second, for content that only changes when someone edits it, such as CMS pages or product descriptions, the docs recommend a long lifetime like max plus a tag that you invalidate on change, rather than polling every few minutes.

Invalidation: updateTag vs revalidateTag

Watercolor decision chart titled Which invalidation should you call, with four cards: updateTag for Server Actions when the user must see their own change, revalidateTag with max for stale-while-revalidate, revalidateTag with expire 0 for webhooks that need data gone now, and cacheLife hours for time-based refresh
Pick the invalidation call by who triggers the change and how much staleness is acceptable.

Next.js 16 split on-demand invalidation into two functions, and choosing the wrong one is the most common source of "my change does not show up" bugs after a migration.

  • updateTag(tag) expires the tag immediately so the next read waits for fresh data. It only works in Server Actions and is meant for read-your-own-writes: an admin edits a price and must see the new price on the redirect.
  • revalidateTag(tag, 'max') marks the data stale. The next request is served the stale version while a background refresh runs. It works in Server Actions and Route Handlers and suits content where a brief delay is fine.
  • revalidateTag(tag, { expire: 0 }) is the option for webhooks and other callers outside a Server Action that need the old data gone now. The next request blocks on a fresh render.

The single-argument revalidateTag(tag) is deprecated; the revalidateTag reference says it currently behaves like { expire: 0 } but may be removed. Search your codebase for it during the migration.

// app/api/cms-webhook/route.ts — called by the CMS after an edit
import { revalidateTag } from 'next/cache'

export async function POST(request: Request) {
  // verify the webhook signature first
  const { productId } = await request.json()
  revalidateTag(`product:${productId}`, 'max')
  revalidateTag('products', 'max')
  return Response.json({ ok: true })
}

Tag at the granularity you invalidate at. A single products tag is simple but means one product edit refreshes every catalog entry. Per-entity tags (product:123) plus a list tag is a reasonable default. If you are wiring this to commerce or CMS webhooks, the same verification and idempotency rules from our guide to Shopify webhook HMAC checks and retries apply: a forged webhook that can trigger revalidation is a cheap way to load your origin.

What our lab measured

Watercolor bar charts from the lab: single request total time 134 ms uncached versus 16.8 ms with use cache, catalog queries during a 15 second load test 2,256 versus 0, and throughput at 20 connections 149 versus 180 requests per second
Next.js 16.4.3 with next start, Node 20, one local run on an 8 vCPU box. Treat the shape, not the exact numbers, as the finding.

To check the behaviour rather than repeat the docs, we built a minimal Next.js 16.4.3 app with Cache Components enabled. Two pages render the same 24-item catalog from a simulated query that waits 120 ms and increments a counter. /dynamic reads it at request time (after connection()); /cached reads it through a 'use cache' function with cacheLife('hours') and cacheTag('products'), plus a cookie-driven greeting in a Suspense boundary. We ran the production server and measured with curl and autocannon.

  • Build output. Both pages were marked ◐ (Partial Prerender). The cached page also showed its lifetime in the build table: revalidate 1h, expire 1d. Time to first byte was about 4 to 5 ms for both, because both send the static shell immediately.
  • Single requests. Median total time over five sequential requests was 134 ms uncached and 16.8 ms cached. The uncached number is the 120 ms query plus rendering.
  • Load. At 20 connections for 15 seconds, the uncached page ran the catalog query 2,256 times; the cached page ran it zero times. Throughput rose from 149 to 180 requests per second, a smaller gain than the query savings suggest, because at that point rendering CPU on a single Node process became the limit.
  • Invalidation. After revalidateTag('products', 'max'), the first request still returned the old version and a request one second later returned the new one: stale-while-revalidate as documented. After revalidateTag('products', { expire: 0 }), the very next request returned the new version and took about 151 ms because it waited for the query.

The practical lesson is that 'use cache' protects your data layer much more than it speeds up a CPU-bound render. If a page is slow because of rendering, look at component work and payload size as well; the levers in our Core Web Vitals guide for LCP, INP and CLS apply to any React storefront.

Migrating an existing app

The official migration guide is thorough. This is the order we would follow on a real codebase.

1. Upgrade, enable, and get it building

Move to Next.js 16 first, then turn on the flag. Routes that still export dynamic, revalidate or fetchCache will error. To keep shipping while you convert, the guide provides a codemod that adds export const instant = false to every page, layout and default file, which defers validation for those segments without forcing them dynamic:

npx @next/codemod@canary cache-components-instant-false ./app

Pass ./src/app if you use a src/ directory; a wrong path reports zero changes rather than failing. Synchronous calls like new Date(), Math.random() and crypto.randomUUID() during prerender still break the build even with the opt-out, so fix those early by moving them behind connection() inside Suspense or into a Client Component.

2. Translate segment configs

  • dynamic = 'force-dynamic': delete it. Uncached data already runs per request.
  • revalidate = 3600: replace with 'use cache' and cacheLife('hours') around the data or component that should be reused.
  • dynamic = 'force-static': replace with 'use cache' and cacheLife('max'). If the route must stay fully static, add export const ensureStatic = 'navigation' (16.4+), which fails the build if dynamic content sneaks in.
  • fetch(url, { next: { revalidate, tags } }): wrap the fetch in a cached function and use cacheLife and cacheTag.
  • dynamicParams: remove it; it is not supported. generateStaticParams must return at least one param, and unknown params are rendered on request (call notFound() for invalid ones).

3. Move request data into Suspense

Reading cookies, headers or searchParams at the top of a page used to make the whole route dynamic. Now it surfaces a validation insight. Push those reads into a small component wrapped in <Suspense> so the rest of the page can be prerendered, and pass params promises down instead of awaiting them at the top.

4. Convert one route at a time

Remove instant = false from a route, resolve its insights, ship, repeat. unstable_cache keeps working, so you do not need to touch it in the first pass.

Common mistakes and limitations

  • Assuming the cache survives deploys and serverless instances. The default 'use cache' handler is in-memory LRU. Self-hosted servers keep entries across requests; serverless instances typically do not, and no directive carries entries across deployments because the build ID is part of the key. The fetch Data Cache and unstable_cache do persist across deployments, so swapping them for 'use cache' can quietly increase origin load. Use 'use cache: remote' or a custom cacheHandlers configuration when you need shared storage.
  • Caching per-user data by accident. Passing a user ID into a cached function is legitimate, but it creates one entry per user. Thousands of users times several cached functions is a memory problem on a self-hosted box; set cacheMaxMemorySize deliberately or keep personalised data uncached.
  • Passing promises of request data into cached scopes. Handing a cookies() promise to a cached component makes the build hang until a 50-second timeout. Await it outside and pass the value.
  • Using updateTag in a Route Handler. It throws. Webhooks need revalidateTag with a profile or { expire: 0 }.
  • Forgetting UI state preservation. Cache Components keeps routes mounted in React's Activity "hidden" mode during navigation, so form inputs, open dropdowns and useActionState messages persist when users come back. Add explicit reset logic where that is wrong.
  • Static export. 'use cache' is not supported with static export, and adapter support is platform-specific.

If your application cache lives outside Next.js, for example a Redis layer in front of an API, the invalidation and tenancy problems are the same ones covered in our Redis caching guide for SaaS APIs. Decide which layer owns freshness for each piece of data so you are not debugging two caches at once.

Recommendations

  1. New projects: keep the create-next-app defaults. Put 'use cache' on data functions first, add a cacheLife to every one, and tag anything that changes on an event.
  2. Existing apps: upgrade to the latest 16.x for the security fixes, enable the flags on a branch, run the instant = false codemod, and convert your highest-traffic routes first.
  3. Write down your tag scheme (entity:id plus list tags) before you start, and map each mutation to the tags it must invalidate.
  4. Decide where the cache lives before you go live. If you deploy to serverless or run several instances, test with a remote handler instead of discovering cold caches in production.
  5. Measure before and after. Count queries per request and watch origin load, not just page timings. The biggest win in our lab was 2,256 fewer database calls, not the latency.

FAQ

Do I have to migrate to Cache Components now?

No. The previous model still works in Next.js 16. The 16.4 announcement says Cache Components will become the default in Next.js 17, so migrating on your own schedule is cheaper than doing it during a major upgrade.

Is unstable_cache deprecated?

It keeps working with Cache Components and the migration guide treats converting it as optional. Remember it persists across deployments while 'use cache' does not, which may be a reason to keep it for specific data.

Can a cached component contain dynamic parts?

Yes, through composition. Content passed in as children or other JSX slots passes through a cached component without becoming part of its cache entry, so a cached layout can wrap a request-time component.

How do I debug what is cached?

Run with NEXT_PRIVATE_DEBUG_CACHE=1 for verbose cache logs, read the build table for lifetimes per route, and in development watch the dev overlay, which reports validation insights instead of failing the HTTP response.

Conclusion

Cache Components replaces guesswork with declarations: dynamic by default, cached where you say so, and invalidated by tags you control. The migration is mostly mechanical, but the decisions underneath it, such as what can be stale, for how long, and where cache entries live, are architecture decisions. Make them on purpose and the result is a faster shell and far fewer queries hitting your database.

If you are planning a Next.js 16 upgrade or want a second pair of eyes on a caching strategy, our web application development team works on exactly this kind of performance and architecture work. Get in touch and tell us what your app looks like today.

← Back to Blog