Skip to content

use cache and Cache Components

Cache Components flips the model: instead of remembering per-fetch cache options, you mark a function, file, or component with the 'use cache' directive to say “cache this,” then shape its lifetime and invalidation with cacheLife and cacheTag.

Add the string 'use cache' at the top of a function (or file) and everything it returns is cached — a fetch, a database query, or a whole rendered component. It replaces scattering cache options across individual calls.

lib/data.ts
import { cacheLife, cacheTag } from 'next/cache'
async function getData() {
'use cache'
cacheLife('hours')
cacheTag('data')
const res = await fetch('https://api.example.com/data')
return res.json()
}

Code written against Next.js 14 (or an AI model trained on it) still uses unstable_cache — wrap the function, pass a key-parts array, pass an options object. The bundled docs now say plainly: “This API has been replaced by use cache in Next.js 16.” It still works (it has not been removed), but the mapping is worth knowing cold, because it comes up in every review of older code:

unstable_cache'use cache' equivalent
unstable_cache(fn, keyParts, options) wraps the function from outside'use cache' goes inside the function itself, as its first line
keyParts: ['posts'] — a manual cache-key suffixNot needed — 'use cache' derives the key from the function’s own arguments and closure automatically
options.tags: ['posts']cacheTag('posts'), called inside the function
options.revalidate: 3600 (seconds)cacheLife({ revalidate: 3600 }), or a named profile like cacheLife('hours')
Works with cacheComponents offRequires cacheComponents: true
Docs status in Next.js 16Deprecated, still importable — not removed
lib/posts-old.ts
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'
// Old model: keyParts is a manual cache-key suffix; tags/revalidate live in
// the options object.
export const getPosts = unstable_cache(
async () => db.post.findMany(),
['posts'],
{ tags: ['posts'], revalidate: 3600 },
)
lib/posts-new.ts
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db'
// New model: no manual key — 'use cache' derives it from the function's
// arguments and closure. Tag and lifetime are set inline, in scope.
export async function getPosts() {
'use cache'
cacheTag('posts')
cacheLife({ revalidate: 3600 })
return db.post.findMany()
}
lib/db.ts
export const db = {
post: {
findMany: async () => [] as { id: string; title: string }[],
},
}

cacheLife sets how long the entry stays fresh — a named profile like 'hours' or an explicit object. Next.js ships six named profiles besides default, each balancing the same three timing properties (stale, revalidate, expire):

ProfileUse casestalerevalidateexpire
defaultStandard content5 min15 minnever
secondsReal-time data (stock prices, live scores)30 s1 s1 min
minutesFrequently updated (social feeds, news)5 min1 min1 hour
hoursMultiple daily updates (product inventory, weather)5 min1 hour1 day
daysDaily updates (blog posts, articles)5 min1 day1 week
weeksWeekly updates (podcasts, newsletters)5 min1 week30 days
maxRarely changes (legal pages, archived content)5 min30 days1 year

You can also define your own named profile in next.config.ts, alongside or instead of using the built-in names — see Custom cache profiles below for a full example built and checked in the probe.

cacheTag attaches a tag so you can invalidate the entry on demand, and a single call can attach more than one: cacheTag('products', 'product-42') tags the same entry both broadly (every product) and specifically (this one product), so a mutation can invalidate at whichever granularity makes sense. revalidateTag(tag, 'max') marks a tag stale and serves the old entry while refreshing (in Next.js 16 the second argument is required — the one-argument form is deprecated and a TypeScript error), and inside a Server Action updateTag(tag) expires it immediately so the user reads their own write.

import { cacheLife, cacheTag } from 'next/cache'
async function getProduct(id: string) {
'use cache'
cacheTag('products', `product-${id}`)
cacheLife({ stale: 60, revalidate: 300 })
return db.product.findUnique({ where: { id } })
}

Turning it on: cacheComponents and Partial Prerendering

Section titled “Turning it on: cacheComponents and Partial Prerendering”

'use cache' requires the cacheComponents flag. In Next.js 16 that same flag is also how you opt into Partial Prerendering — the older experimental ppr flag and experimental_ppr export were removed. With it on, Next.js prerenders a static shell and streams the dynamic, uncached holes in at request time.

next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig

Extend the same config file with your own named cacheLife profiles, alongside the flag:

// next.config.ts — extends the minimal config above with a custom profile
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
cacheLife: {
biweekly: {
stale: 60 * 60 * 24 * 14, // 14 days
revalidate: 60 * 60 * 24, // 1 day
expire: 60 * 60 * 24 * 14, // 14 days
},
},
}
export default nextConfig
// app/catalog/page.tsx — only type-checks against the config above, not the harness's shared probe config
import { cacheLife } from 'next/cache'
async function getData() {
'use cache'
cacheLife('biweekly')
return { now: Date.now() }
}
export default async function CatalogPage() {
const data = await getData()
return <p>{data.now}</p>
}

cacheLife’s type overloads for a custom profile name are generated from whichever next.config.ts is live at the probe root during next dev/next build/next typegen — a lesson fence named next.config.ts does not itself change that for the shared type-check harness, which is why this pair was verified with a real, isolated next build (below) rather than left for automatic collection.

Built and checked in the probe, the build output confirms the custom profile’s exact numbers made it through:

Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
└ ○ /catalog 1d 2w

Two variants extend the model, both shipped with Cache Components in Next.js 16. 'use cache: private' may read cookies(), headers(), and searchParams directly; its result is never written to a shared server cache — it is kept per browser, in the client’s memory for the stale window, so it is never shared across visitors. 'use cache: remote' stores the entry in a shared, durable cache handler (Redis, KV, or whatever the platform provides via cacheHandlers) so every server instance and every user hits the same entry, at the cost of a network round trip.

import { cacheLife } from 'next/cache'
import { cookies } from 'next/headers'
async function getRecommendations() {
'use cache: private'
cacheLife({ stale: 60 })
const sessionId = (await cookies()).get('session-id')?.value
return db.recommendations.forSession(sessionId)
}
flowchart TD
  A["cacheComponents: true"] --> B["'use cache' function or component"]
  A --> C["Partial Prerendering"]
  B --> D["Cached static shell"]
  C --> D
  D --> E["Dynamic uncached holes stream in"]
cacheComponents: the static shell plus streamed dynamic holes

'use cache' moves the caching decision from the call site (an options object on each fetch) to the definition site (a directive at the top of the function). Once you are inside a 'use cache' scope, cacheLife and cacheTag are just local configuration for that one cached unit — they never leak out, and nothing about how the function is called from outside needs to change. That is the whole shift from the old model: caching becomes a property of the function, not of each call.

What you getBenefitCost
'use cache' on a functionNo manual cache key, tags/lifetime live next to the logic they describeRequires cacheComponents: true — an all-or-nothing flag for the route segment config it replaces
Built-in cacheLife profilesA name communicates intent ('days' reads as “updates daily”)The exact numbers are fixed unless you override them — read the table if precision matters
Custom cacheLife profile in next.config.tsExact control, reusable by name across the appOne more thing to keep in sync — the name only means what the config currently says it means
Multiple cacheTag values on one entryInvalidate broadly ('products') or narrowly ('product-42') from different mutationsEvery extra tag is one more thing that must be revalidated correctly, or a change looks invalidated when it is not
  • “unstable_cache was removed in Next.js 16.” It was not — it still compiles and runs, but the docs mark it replaced by 'use cache' and recommend migrating.
  • “'use cache' needs a manual cache key like unstable_cache’s keyParts.” It does not — the directive derives the key from the function’s own arguments and closure automatically.
  • “Adding a second cacheTag replaces the first one.” It does not — cacheTag('products', 'product-42') attaches both tags to the same entry; either one alone can invalidate it later.
  • “'use cache: private' is just a shorter-lived version of the regular cache.” It is a different storage location entirely — never written to a shared server cache, kept per browser instead, specifically so it is safe to read cookies()/headers() inside it.

When a generated snippet uses unstable_cache or 'use cache', check for these before trusting it:

  • Is unstable_cache used with no tags in its options object? Without a tag, nothing can ever target that entry with revalidateTag — it can only expire on its revalidate timer or a full redeploy, which silently defeats on-demand invalidation.
  • Does a 'use cache' function call cacheLife at all? Omitting it does not error — it silently falls back to the default profile (15-minute revalidate), which is easy to mistake for “no caching.”
  • Is cacheTag called with a single tag when the entry logically needs two (a collection-level tag and an item-level tag)? cacheTag accepts multiple values in one call — no need for two separate calls or to pick just one granularity.
  • Does the code assume 'use cache' works without cacheComponents: true in next.config.ts? It does not — the directive requires the flag, and old route-segment-config exports (dynamic, revalidate, fetchCache) fail the build once it is on.

🐛 Spot the bug

import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'
export const getPosts = unstable_cache(async () => {
return db.post.findMany()
})
Show the answer

No tags were passed in the (omitted) options object, and no keyParts array was passed either. The bundled docs describe tags as what revalidateTag uses to target an entry — without one, this cached result can only ever go stale on its own timer (there is no revalidate here either, so it is cached indefinitely) or when the app redeploys. A Server Action that later calls revalidateTag('posts') after creating a post will do nothing to this entry, because it was never tagged 'posts' — a bug that produces no error, no warning, just stale data forever. The fix is unstable_cache(async () => db.post.findMany(), ['posts'], { tags: ['posts'] }), or better, migrate it to 'use cache' with an explicit cacheTag('posts').

What does the use cache directive do?
Which config flag enables use cache?
What set the lifetime and tag of a use cache entry?
In Next.js 16, what does cacheComponents also enable?
Migrated to 'use cache', which pair of calls is the direct equivalent of the options object?
export const getPosts = unstable_cache(
  async () => db.post.findMany(),
  ['posts'],
  { tags: ['posts'], revalidate: 3600 },
)
A Server Action later calls revalidateTag('posts', 'max') after creating a new post. What happens to this cached entry?
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'

export const getPosts = unstable_cache(async () => {
  return db.post.findMany()
})