Dev & EngARTICLE

I migrated an App Router route to 'use cache' in Next.js 16 (and measured the real gain)

I took a route with a slow fetch, applied the 'use cache' directive with cacheLife and cacheTag, and compared TTFB and perceived performance before and after. Here's the step-by-step, tripping points included.

I migrated an App Router route to 'use cache' in Next.js 16 (and measured the real gain)
Image: Carina Ferreira

use cache is no longer experimental: in Next.js 16 it's part of the Cache Components feature. The promise is good (mark a route, component, or function as cacheable with one line), but the directive comes with rules that break the build if you don't understand the model. I took a real listing route to migrate and measure. Here's the path I took, including the errors that showed up.

The starting point

My route app/products/page.tsx made a catalog fetch on every request. Without cache, TTFB depended entirely on the downstream API. I measured the baseline with Next.js DevTools (performance tab) and ran Lighthouse in mobile mode, with throttling, three times, keeping the median. Note this before touching anything: a metric without a baseline is guesswork.

Step 1: enable Cache Components

The directive only exists if you turn on the flag in next.config.ts:

ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

Step 2: apply 'use cache' to the data-fetching function

Instead of caching the entire page right away, I started with the function that fetches the data. It's the smallest blast radius:

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

async function getProducts() {
  'use cache'
  cacheLife('hours')
  cacheTag('products')
  const res = await fetch('https://api.exemplo.com/products')
  return res.json()
}

Two important pieces here:

  • cacheLife('hours') swaps the default profile (which is stale 5 min on the client, revalidate 15 min on the server) for an hours profile.
  • cacheTag('products') creates a tag to invalidate on demand later.

Step 3: on-demand invalidation

When the catalog changes, I don't want to wait for the TTL. A Server Action handles it:

ts
'use server'
import { updateTag } from 'next/cache'

export async function updateProduct() {
  await db.products.update(/* ... */)
  updateTag('products')
}

The elegant detail from the docs: cacheLife and cacheTag apply both at the server layer and the client layer. You configure the semantics in one place only.

The trip-up: build stuck when caching the entire page

When I tried to cache the entire page, the build got stuck during prerender and blew up. The error follows the call stack: a helper function called by the cached function that reads cookies(), headers(), or searchParams fails with what's called the next-request-in-use-cache error.

The reason: I was reading cookies() in a parent component and passing the Promise as a prop into the cached scope. As the docs explain, cached functions cannot access request APIs (cookies(), headers(), searchParams). The cache tries to resolve something that only exists at runtime, and it locks up.

The fix is the recommended pattern: read the value outside the cached scope and pass it as a serializable argument.

tsx
async function Dynamic() {
  const store = await cookies()
  const theme = store.get('theme')?.value ?? 'light'
  return <Cached theme={theme} />
}

async function Cached({ theme }: { theme: string }) {
  'use cache'
  // theme enters the cache key because it's a serializable argument
  return <div className={theme}>...</div>
}

It's worth understanding the cache key: it's generated from the Build ID, the function ID, and the serializable arguments. Variables captured from closures are also included automatically. That's why primitives and plain objects pass, but class instances, functions (except pass-through), and URL don't.

Step 4: composing without invalidating the cache

Part of the page was genuinely dynamic (personalized recommendations). Instead of giving up on caching, I used the pass-through pattern: dynamic content enters as children and passes through the cached component without affecting the entry, as long as I don't read children inside the cached body.

tsx
export default function Page() {
  return (
    <CachedShell header={<h1>Catálogo</h1>}>
      <Recommendations /> {/* dynamic, just passes through */}
    </CachedShell>
  )
}

The measured gain

Comparing the medians of three runs:

  • TTFB dropped significantly on requests that hit the warm cache, because the server no longer had to wait for the downstream API.
  • In Lighthouse, LCP improved as well, since the shell arrived ready.

An honest warning straight from the docs: in serverless, cache entries typically don't persist across requests (each request can be a different instance). Build-time caching works, but runtime caching may not survive. In self-hosted setups, the cache persists in memory, controlled by cacheMaxMemorySize. If your environment is serverless and you need real persistence, the path is use cache: remote with Redis/KV, which brings cost and network latency. Measure it in your own environment, not mine.

The lesson I took away: use cache is easy to write and annoying to get right. The correct mental model is always the same: runtime data stays out, it comes in as a serializable argument.

Source 1: Next.js — Directive: use cache (https://nextjs.org/docs/app/api-reference/directives/use-cache)

Translated from the Brazilian Portuguese original · Read the original

Read also