Skip to content

fig. Bnextjs

Next.js 16 Cache Components: A Practical Guide to "use cache"

Next.js 16 makes caching opt-in. Learn how Cache Components, the use cache directive, cacheLife and cacheTag work, with copy-paste examples.

Written by
Mayank Panchal
Published
Reading time
4 min
Filed under
nextjs
  • nextjs
  • caching
  • react
On this page · 7
  1. Turning it on
  2. The directive
  3. Choosing how long: cacheLife
  4. Invalidating on demand: cacheTag
  5. What you cannot cache: request data
  6. Everything else streams
  7. A migration checklist

For years, caching in the Next.js App Router worked by default and you opted out. Next.js 16 flips that. With Cache Components, nothing is cached unless you say so, and the way you say so is a single directive: "use cache".

This post covers what changed, how the directive behaves, and the patterns that cover most real pages.

Turning it on#

Cache Components sit behind one flag in next.config:

TypeScript
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

With the flag on, a route is a static shell (everything that can be prerendered) plus dynamic holes that stream in at request time. This is the idea behind Partial Prerendering, now expressed through ordinary components instead of route-level switches.

The directive#

Put "use cache" at the top of an async function, an async component, or a whole file:

TSX
import { cacheLife } from 'next/cache'

export async function getUsers() {
  'use cache'
  cacheLife('hours')
  return db.query('SELECT * FROM users')
}

Three rules to remember:

  1. It must be async. Cached functions and components return promises.
  2. Arguments are the cache key. Along with the build ID and the function's identity, the serialisable arguments (or props) form the key. Different userId, different entry.
  3. Closures count too. A variable captured from an outer scope is bound as an argument and becomes part of the key.

That second rule is why you never write cache keys by hand.

Choosing how long: cacheLife#

cacheLife takes a preset profile name or an object:

TSX
cacheLife('days')              // a preset
cacheLife({ revalidate: 900 }) // refresh in the background after 15 minutes

Under the hood a profile has three numbers:

Setting Meaning
stale How long the client may reuse its copy without asking the server
revalidate After this, the next request triggers a background refresh
expire After this with no traffic, the next request waits for fresh data

Set cacheLife explicitly in every use cache scope. Leaving it out silently applies the default profile, which is hard to spot when you read the code later.

Invalidating on demand: cacheTag#

Time-based expiry is not enough when content changes because someone edited it. Tag the entry, then invalidate the tag after a mutation:

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

export async function getPost(slug: string) {
  'use cache'
  cacheLife('max')
  cacheTag(`post:${slug}`)
  return db.post.findUnique({ where: { slug } })
}
TypeScript
'use server'
import { updateTag } from 'next/cache'

export async function savePost(slug: string, data: PostInput) {
  await db.post.update({ where: { slug }, data })
  updateTag(`post:${slug}`)
}

A long cacheLife plus a tag is the pattern for content that changes only when a person edits it. A short profile suits lists that drift through the day.

What you cannot cache: request data#

A cached scope has to produce the same output for the same inputs, so it can't read cookies() or headers() directly. Read them outside, then pass the values in:

TSX
import { cookies } from 'next/headers'

export default async function Page() {
  const locale = (await cookies()).get('locale')?.value ?? 'en'
  return <Prices locale={locale} />
}

async function Prices({ locale }: { locale: string }) {
  'use cache'
  cacheLife('hours')
  return <PriceTable data={await getPrices(locale)} />
}

If you truly cannot refactor, there is a "use cache: private" variant, and "use cache: remote" lets a platform supply a shared cache handler. Both have trade-offs, so reach for them last.

Everything else streams#

Components that need fresh data on every request simply don't use the directive. Wrap them in <Suspense> so the cached shell can ship immediately:

TSX
import { Suspense } from 'react'

export default function Page() {
  return (
    <>
      <Header />            {/* prerendered into the shell */}
      <Suspense fallback={<p>Loading…</p>}>
        <LiveStats />       {/* fetched per request */}
      </Suspense>
    </>
  )
}

Without the boundary, the dev overlay flags a blocking route and tells you to add one. Note that <Suspense> does not make a component dynamic by itself; synchronous work still completes during prerendering.

A migration checklist#

  • Enable cacheComponents and run next dev, which surfaces blocking routes as you navigate.
  • Put "use cache" and an explicit cacheLife on the data functions you used to rely on implicit caching for.
  • Add cacheTag wherever a mutation should refresh the data.
  • Wrap per-request components in <Suspense>.

The mental model is smaller than the old one: cache what you mark, stream what you don't.

Written by

Mayank Panchal

Software Developer & Full-Time Learner. Coding on the clock, learning around the clock. Software developer in Bengaluru - Next.js, FastAPI, PostgreSQL, AI-native tooling.

fig. B.1keep reading

fig. B.2contact

Let’s build something sharp.