Skip to content

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 (plus partialPrefetching: 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' with cacheLife. Without it, the default profile applies (5 minutes stale, 15 minutes revalidate, never expires).
  • revalidateTag now needs a second argument, for example revalidateTag('posts', 'max'). Use updateTag in 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 (fetch options, 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:

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

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

export default nextConfig

At build time, Next.js renders each route and sorts every component into one of three buckets:

  1. Static: pure computation, module imports, synchronous file reads. Prerendered automatically.
  2. Cached: anything inside 'use cache'. Included in the static shell if its lifetime is not too short.
  3. 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:

TSX
// 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.
TypeScript
// 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:

TypeScript
// 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:

TypeScript
// 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:

TSX
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.

TSX
// 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":

TSX
// 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 the default profile. Be explicit.
  • Calling one-argument revalidateTag. Deprecated. Pass 'max' or another profile.
  • Using updateTag in a Route Handler. It throws. Use revalidateTag.
  • 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

Next.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 article