Next.js8 min read
Next.js Caching Explained: use cache, cacheLife, ISR
Next.js caching explained for Next.js 16: 'use cache', cacheLife, cacheTag, revalidateTag vs updateTag vs revalidatePath, ISR and fetch caching.
Next.js caching in version 16 works like this: nothing is cached unless you ask for it. You mark a function or component with the 'use cache' directive, set how long it lives with cacheLife, label it with cacheTag, and invalidate it with updateTag, revalidateTag or revalidatePath. Anything uncached or request-specific streams in behind <Suspense>, while cached and static parts are prerendered into a static shell.
That model is called Cache Components. It shipped in Next.js 16.0, and since 16.4 it is on by default in new create-next-app projects. If you learned Next.js caching in the 13 or 14 era, a lot of what you remember is now wrong, so let's go through the current model properly.
Key takeaways
- Turn on Cache Components with
cacheComponents: true(pluspartialPrefetching: true, which the docs now ask you to set explicitly). It becomes the default in Next.js 17. 'use cache'caches the return value of an async function or component. Arguments and captured variables become the cache key automatically.- Always pair
'use cache'withcacheLife. Without it, thedefaultprofile applies (5 minutes stale, 15 minutes revalidate, never expires). revalidateTagnow needs a second argument, for examplerevalidateTag('posts', 'max'). UseupdateTagin Server Actions when the user must see their change immediately.- Request data (
cookies(),headers(),searchParams) cannot be read inside'use cache'. Read it outside and pass values in. - The old model (
fetchoptions,export const revalidate,unstable_cache) still works when Cache Components is off.
How Next.js caching works with Cache Components
Turn it on in next.config.ts:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
}
export default nextConfigAt build time, Next.js renders each route and sorts every component into one of three buckets:
- Static: pure computation, module imports, synchronous file reads. Prerendered automatically.
- Cached: anything inside
'use cache'. Included in the static shell if its lifetime is not too short. - Dynamic: uncached fetches, database calls,
cookies(),headers(). Must sit inside<Suspense>. The fallback goes into the shell and the real content streams at request time.
This is Partial Prerendering, and it is the default rendering behavior under Cache Components. If you leave dynamic work outside a Suspense boundary, the dev overlay flags the route as blocking and tells you to either cache it or wrap it.
Here is a page that uses all three:
// app/blog/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife, cacheTag } from 'next/cache'
export default function BlogPage() {
return (
<>
<h1>Blog</h1> {/* static */}
<PostList /> {/* cached, part of the static shell */}
<Suspense fallback={<p>Loading your reading list...</p>}>
<ReadingList /> {/* dynamic, streams per request */}
</Suspense>
</>
)
}
async function PostList() {
'use cache'
cacheLife('hours')
cacheTag('posts')
const posts = await db.post.findMany({ orderBy: { createdAt: 'desc' } })
return (
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
)
}
async function ReadingList() {
const userId = (await cookies()).get('uid')?.value
const items = userId ? await getReadingList(userId) : []
return <p>{items.length} saved posts</p>
}Using 'use cache' for data and UI
You can put the directive in three places:
- Inside a data function (
getProducts()), the most reusable option. - Inside a component, which caches its rendered output.
- At the top of a file, which caches every export in it. Every export must then be async.
// lib/data.ts
import { cacheLife, cacheTag } from 'next/cache'
export async function getProduct(id: string) {
'use cache'
cacheLife('days')
cacheTag('products', `product:${id}`)
return db.product.findUnique({ where: { id } })
}id is part of the cache key, so each product gets its own entry. Variables captured from an outer scope also become part of the key, which is convenient but easy to forget when debugging.
Arguments and return values must be serializable: primitives, plain objects, arrays, Date, Map, Set. Class instances and URL objects are not allowed. You can pass children or a Server Action through a cached component as long as you do not read or call them inside.
cacheLife profiles and what stale, revalidate and expire mean
Each profile sets three timers:
- stale: how long the browser can reuse the result without asking the server.
- revalidate: after this, the server serves the cached value and regenerates it in the background.
- expire: after this with no traffic, the next request waits for fresh data.
| Profile | stale | revalidate | expire |
|---|---|---|---|
default |
5m | 15m | never |
seconds |
30s | 1s | 60s |
minutes |
5m | 1m | 1h |
hours |
5m | 1h | 1d |
days |
5m | 1d | 1w |
weeks |
5m | 1w | 30d |
max |
5m | 30d | 1y |
You can also pass an object, such as cacheLife({ stale: 300, revalidate: 900, expire: 86400 }), or define named profiles in next.config.ts.
One rule that catches people: short-lived caches (the seconds profile, revalidate: 0, or expire under 5 minutes) are excluded from prerendering and become dynamic holes. They still need a Suspense boundary.
revalidateTag vs updateTag vs revalidatePath
This is where Next.js 16 changed the API, and most search results are still out of date.
| API | Where | Behavior | Use it for |
|---|---|---|---|
updateTag(tag) |
Server Actions only | Expires immediately, next read waits for fresh data | User edits that must show right away |
revalidateTag(tag, profile) |
Server Actions and Route Handlers | Stale-while-revalidate | CMS webhooks, catalog updates |
revalidatePath(path) |
Server Actions and Route Handlers | Invalidates everything for a path | One route changed and you have no tags |
refresh() |
Server Actions only | Re-renders uncached data, cache untouched | Counters, notifications |
A form that edits a post should use updateTag, so the author sees the new title after the redirect:
// app/posts/actions.ts
'use server'
import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function renamePost(id: string, formData: FormData) {
const title = String(formData.get('title') ?? '').trim()
await db.post.update({ where: { id }, data: { title } })
updateTag('posts')
updateTag(`post:${id}`)
redirect(`/posts/${id}`) // redirect throws, so invalidate first
}A CMS webhook in a Route Handler cannot use updateTag (it throws outside Server Actions), so it uses revalidateTag with a profile:
// app/api/cms-webhook/route.ts
import { revalidateTag } from 'next/cache'
export async function POST(request: Request) {
if (request.headers.get('x-webhook-secret') !== process.env.CMS_SECRET) {
return new Response('Unauthorized', { status: 401 })
}
const { tag } = await request.json()
revalidateTag(tag, 'max')
return Response.json({ revalidated: true })
}The one-argument revalidateTag('posts') is deprecated since Next.js 16. The docs prefer tags over revalidatePath because paths tend to over-invalidate.
For a deeper look at the mutation side, see the Next.js Server Actions guide.
Caching with cookies, headers and per-user data
Plain 'use cache' cannot call cookies() or headers(), and neither can any helper it calls. The fix is to read the value outside and pass it in:
async function Profile() {
const userId = (await cookies()).get('uid')?.value
if (!userId) return null
return <ProfileCard userId={userId} />
}
async function ProfileCard({ userId }: { userId: string }) {
'use cache'
cacheLife('minutes')
cacheTag(`user:${userId}`)
const user = await getUser(userId)
return <p>{user.name}</p>
}Two variants exist for edge cases:
'use cache: private'can read cookies and headers directly, but the result is only cached in the browser, never on the server. The docs use it for the "current user" lookup.'use cache: remote'stores entries in a shared cache handler (Redis, KV and similar) instead of per-instance memory. It costs a network round trip, so it only pays off at a high hit rate.
Cache keys and tags are stored as plain text. Use a user ID, never an email or token.
ISR with Cache Components
Incremental Static Regeneration still exists, it just looks different. You list known paths with generateStaticParams (it must return at least one). Since 16.3, with Partial Prefetching on and params awaited inside a Suspense boundary, any path you did not list serves an instant App Shell on the first visit, then upgrades to the full prerendered page in the background for everyone after.
// app/products/[id]/page.tsx
export async function generateStaticParams() {
const top = await getTopProducts(50)
return top.map((p) => ({ id: p.id }))
}Combine that with cacheLife('days') and cacheTag on the data, and you have the old getStaticProps + revalidate + on-demand ISR setup with far less config. On Mercury 360, an AI HR platform I built with Next.js, pre-rendering plus background revalidation is the core of how pages are served, and this is the cleanest version of that pattern Next.js has offered so far.
Next.js fetch caching without Cache Components
If you have not enabled cacheComponents, you are on what the docs now call the "previous model":
// Cached until invalidated
await fetch(url, { cache: 'force-cache' })
// Cached, revalidated at most every hour, tagged
await fetch(url, { next: { revalidate: 3600, tags: ['posts'] } })
// Never cached (also the default since Next.js 15)
await fetch(url, { cache: 'no-store' })Route-level export const revalidate = 3600 and export const dynamic = 'force-dynamic' still work there. For non-fetch data, unstable_cache does the job. When you switch Cache Components on, those segment exports start erroring, while existing fetch options and unstable_cache keep working and can be migrated later.
One trade-off to know when you migrate: the old fetch Data Cache and unstable_cache persist across deployments. 'use cache' entries do not, because the build ID is part of the key.
Common Next.js caching mistakes
- Omitting
cacheLife. You silently get thedefaultprofile. Be explicit. - Calling one-argument
revalidateTag. Deprecated. Pass'max'or another profile. - Using
updateTagin a Route Handler. It throws. UserevalidateTag. - Passing a
cookies()promise into a cached component. The build hangs for 50 seconds and fails with a "filling a cache during prerender timed out" error. Await it first and pass the value. - Expecting runtime cache hits on serverless. The default handler is in-memory per instance. Use
'use cache: remote'if the hit rate matters. - Putting secrets in cache keys or tags. They are stored in plain text.
If caching is part of a wider performance push, pair this with the React performance optimization checklist.
FAQ
Is fetch cached by default in Next.js 16?
No. Since Next.js 15, fetch is not cached by default. With Cache Components enabled, nothing is cached unless it runs inside a 'use cache' scope. Without Cache Components, you opt in with cache: 'force-cache' or next.revalidate.
What is the difference between revalidateTag and updateTag?
revalidateTag(tag, profile) marks cached data stale and refreshes it in the background, so the next visitor may still see old data. updateTag(tag) expires the data immediately so the user sees their own change, and it can only be called from a Server Action.
Does revalidateTag still work with one argument?
The single-argument form is deprecated since Next.js 16. Pass a cacheLife profile as the second argument, usually 'max', for stale-while-revalidate behavior.
Can I use cookies() inside 'use cache'?
No. Plain 'use cache' and 'use cache: remote' scopes cannot read cookies(), headers() or searchParams. Read the value outside and pass it as an argument, or use 'use cache: private' when you cannot refactor.
Do 'use cache' entries survive a new deployment?
No. The cache key includes the build ID, so every deploy starts with fresh entries. The default handler is also in-memory, so on serverless platforms runtime entries may not survive between requests unless you use 'use cache: remote'.
Wrapping up
Next.js caching finally has one clear rule: dynamic by default, cached when you say so. Use 'use cache' with an explicit cacheLife, tag what you will need to invalidate, use updateTag for user edits and revalidateTag(tag, 'max') for webhooks, and keep request data behind Suspense. The official caching guide is the reference to bookmark as the model keeps evolving toward Next.js 17.
- #nextjs
- #caching
- #cache-components
- #performance
Keep reading
Next.js8 min read
Next.js Image and Font Optimization for Core Web Vitals
Next.js image and font optimization for Core Web Vitals: next/image sizes and preload, AVIF, next/font fallbacks, and fixes for slow LCP and layout shift.
Read articleNext.js10 min read
Deploy Next.js: Vercel vs Self-Hosting with Docker
Deploy Next.js on Vercel or self-host with Docker and standalone output. Compare costs, features, caching, ISR and the gotchas before you choose a host.
Read articleNext.js8 min read
Next.js App Router vs Pages Router: Migration Guide
Next.js App Router vs Pages Router explained: the real differences, when to migrate, and a step-by-step migration guide updated for Next.js 16.4.
Read article