writing/tutorial/2026/06
TutorialJun 10, 2026·28 min read

Next.js 16 Cache Components: The Complete Guide to use cache, cacheLife & cacheTag

Master the new caching model in Next.js 16. This hands-on guide covers the use cache directive, cacheLife profiles, tag-based invalidation with cacheTag and updateTag, Partial Prerendering, and migrating from implicit caching — with runnable examples.

Next.js 16 quietly rewrote one of the most confusing parts of the framework: caching. For years, the App Router cached aggressively and implicitly — fetches were deduped and stored without you asking, route segments turned static or dynamic based on rules nobody could fully recite, and "why is my data stale?" became a rite of passage.

Cache Components flips the model. Caching is now entirely opt-in. By default, every page renders dynamically at request time — exactly what most developers expect from a full-stack framework. When you want caching, you reach for one explicit directive: 'use cache'. This guide walks through the whole model from scratch, with runnable examples you can drop into a real project.

Prerequisites

Before starting, ensure you have:

  • Node.js 20.9+ installed (Next.js 16 dropped Node 18 support)
  • TypeScript 5.1+ if you use TypeScript
  • A working knowledge of the App Router (Server Components, layouts, Suspense)
  • A new or existing Next.js project you can upgrade

This guide assumes intermediate familiarity with React Server Components. You do not need prior experience with Partial Prerendering — we build that understanding here.

What You'll Build

We'll build a small dashboard route that combines three kinds of content on one page:

  1. A static marketing shell that renders instantly from cache
  2. A cached product list that revalidates on a schedule
  3. A live, per-request section (the signed-in user's notifications) that streams in fresh

By the end, you'll understand exactly which parts of a page are cached, for how long, and how to invalidate them precisely — without guessing.

Step 1: Upgrade to Next.js 16

Start with the automated codemod. It handles the bulk of breaking changes, including async params/searchParams and the middleware.ts rename.

# Automated upgrade (recommended)
npx @next/codemod@canary upgrade latest
 
# ...or upgrade manually
npm install next@latest react@latest react-dom@latest

A few breaking changes matter for this guide. params, searchParams, cookies(), headers(), and draftMode() are now async and must be awaited:

// Before (Next.js 15)
export default function Page({ params }) {
  const { id } = params
}
 
// After (Next.js 16)
export default async function Page({ params }) {
  const { id } = await params
}

Turbopack is now the default bundler. If you have a custom webpack config, opt back in with next build --webpack.

Step 2: Enable Cache Components

Cache Components is a single config flag that unifies what used to be three separate experimental flags (ppr, dynamicIO, and useCache).

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

The moment you flip this on, two things happen:

  • Everything dynamic runs at request time by default. No more accidental caching.
  • Partial Prerendering (PPR) becomes the default behavior. The old experimental.ppr flag and the experimental_ppr route export are gone — Cache Components replaces them entirely.

If you previously used experimental.dynamicIO or experimental.useCache, remove those flags; cacheComponents is their successor.

Step 3: Understand the Static Shell

Here's the mental model that makes everything else click. With Cache Components enabled, Next.js prerenders a static HTML shell for each route and serves it immediately. Anywhere your code reads dynamic data (cookies, headers, uncached fetches), you wrap that part in a <Suspense> boundary. Next.js streams the dynamic content into the shell when it's ready.

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { Notifications } from './notifications'
 
export default function DashboardPage() {
  return (
    <main>
      {/* Static shell — prerendered, served instantly */}
      <h1>Dashboard</h1>
      <p>Welcome to your control center.</p>
 
      {/* Dynamic — streamed in per request */}
      <Suspense fallback={<p>Loading notifications…</p>}>
        <Notifications />
      </Suspense>
    </main>
  )
}

The <h1> and <p> are part of the static shell and load instantly on every visit. <Notifications /> reads per-request data, so it lives behind a Suspense boundary and streams in. This is PPR in action: one route, both static and dynamic, no compromise on initial load.

If a component reads dynamic data without a Suspense boundary around it, Next.js will error at build time and tell you exactly where to add one. That error is a feature — it forces you to decide, explicitly, what is static and what is dynamic.

Step 4: Cache a Function with use cache

Now the headline feature. Add the 'use cache' directive to a function, component, or whole file to make its output cacheable. Next.js inspects the function's arguments and closure values to automatically generate a cache key — you never write one by hand.

// app/dashboard/products.tsx
import { cacheLife } from 'next/cache'
 
async function getProducts() {
  'use cache'
  cacheLife('hours')
 
  const res = await fetch('https://api.example.com/products')
  return res.json()
}
 
export async function ProductList() {
  const products = await getProducts()
 
  return (
    <ul>
      {products.map((p: { id: string; name: string }) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  )
}

Because getProducts is marked 'use cache', its result is stored and reused across requests and across the entire deployment — not just for one user. The first visitor warms the cache; everyone after reads the stored value until it expires.

You can also cache an entire component by putting the directive at the top of the function:

export async function ProductList() {
  'use cache'
  cacheLife('hours')
 
  const products = await getProducts()
  return <ul>{/* ... */}</ul>
}

Or cache every export in a file by placing 'use cache' at the very top of the module — useful for a file of pure data-fetching helpers.

Step 5: Control Freshness with cacheLife

A cached value needs a lifetime. cacheLife lets you pick a named profile instead of scattering magic numbers across your codebase. Next.js ships built-in profiles, and each encodes both a stale time and a revalidation budget:

ProfileUse it for
'seconds'Near-real-time data tolerant of tiny staleness
'minutes'Frequently changing content (feeds, prices)
'hours'Content that updates a few times a day
'days'Daily-refreshed marketing or catalog data
'weeks'Rarely changing reference content
'max'Effectively permanent until explicitly invalidated
async function getHomepageBanner() {
  'use cache'
  cacheLife('days') // refresh roughly once a day
  return fetchBanner()
}

For full control, define a custom profile in your config and reference it by name:

// next.config.ts
const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheLife: {
    // a 5-minute fresh window, revalidate in the background after
    realtime: {
      stale: 60,      // serve from client cache for 60s
      revalidate: 300, // refresh on the server every 5 min
      expire: 3600,    // hard expiry after 1 hour
    },
  },
}
async function getMetrics() {
  'use cache'
  cacheLife('realtime')
  return fetchMetrics()
}

Step 6: Tag and Invalidate with cacheTag

Time-based expiry is fine for content on a clock, but most apps need to invalidate because something changed. That's what tags are for. Attach one or more tags to a cached entry with cacheTag, then invalidate by tag later.

import { cacheLife, cacheTag } from 'next/cache'
 
async function getProduct(id: string) {
  'use cache'
  cacheLife('max')
  cacheTag(`product-${id}`, 'products')
 
  const res = await fetch(`https://api.example.com/products/${id}`)
  return res.json()
}

This entry now carries two tags: a specific product-123 and a broad products. You can blow away one product or the whole catalog with a single call. Tag-based invalidation is the right model for any app with a non-trivial data graph — it participates in PPR cleanly and gives you precise control.

Step 7: Choose the Right Invalidation API

Next.js 16 ships three invalidation primitives, and picking the right one is the difference between a snappy app and a confusing one. Here's the decision:

revalidateTag(tag, profile) — for eventual consistency

revalidateTag now requires a cacheLife profile as its second argument, enabling stale-while-revalidate. Users get cached data instantly while Next.js refreshes in the background. Ideal for content that can tolerate being a few seconds out of date.

import { revalidateTag } from 'next/cache'
 
// Recommended default for long-lived content
revalidateTag('products', 'max')
 
// Or an inline custom window
revalidateTag('products', { expire: 3600 })

The single-argument form revalidateTag('products') is deprecated — always pass a profile.

updateTag(tag) — for read-your-writes

When a user submits a form and must see their change immediately, use updateTag inside a Server Action. It expires the cache and reads fresh data within the same request — no flash of stale content.

'use server'
 
import { updateTag } from 'next/cache'
 
export async function updateProduct(id: string, data: ProductData) {
  await db.products.update(id, data)
 
  // Expire + immediately re-read: the user sees their edit right away
  updateTag(`product-${id}`)
}

refresh() — for uncached data

refresh doesn't touch the cache at all. It re-renders uncached dynamic data elsewhere on the page after an action — like a notification count or a live status badge.

'use server'
 
import { refresh } from 'next/cache'
 
export async function markAsRead(notificationId: string) {
  await db.notifications.markAsRead(notificationId)
  refresh() // re-fetch the uncached notification count in the header
}

A simple rule of thumb: updateTag when the user changed cached data and must see it now; revalidateTag when staleness is acceptable for a moment; refresh when the data wasn't cached to begin with.

Step 8: Assemble the Full Dashboard

Now combine everything into one route that demonstrates the three tiers — static shell, cached list, and live per-request content.

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { cacheLife, cacheTag } from 'next/cache'
 
// Tier 2: cached product list, tagged for precise invalidation
async function ProductList() {
  'use cache'
  cacheLife('hours')
  cacheTag('products')
 
  const res = await fetch('https://api.example.com/products')
  const products = await res.json()
 
  return (
    <ul>
      {products.map((p: { id: string; name: string }) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  )
}
 
// Tier 3: live, per-request — reads cookies, never cached
async function Notifications() {
  const res = await fetch('https://api.example.com/me/notifications', {
    headers: { /* per-user auth from cookies */ },
  })
  const items = await res.json()
  return <span>{items.length} new</span>
}
 
export default function DashboardPage() {
  return (
    <main>
      {/* Tier 1: static shell — instant */}
      <h1>Dashboard</h1>
 
      {/* Tier 2: cached, streams from store */}
      <section>
        <h2>Products</h2>
        <Suspense fallback={<p>Loading products…</p>}>
          <ProductList />
        </Suspense>
      </section>
 
      {/* Tier 3: dynamic, streams fresh per request */}
      <section>
        <h2>Notifications</h2>
        <Suspense fallback={<p>Checking…</p>}>
          <Notifications />
        </Suspense>
      </section>
    </main>
  )
}

One route, three caching behaviors, each chosen explicitly. The header renders instantly, products stream from the cache, and notifications stream fresh for every user.

Step 9: Migrate from Implicit Caching

If you're upgrading an existing App Router app, here's how the old patterns map to the new world.

Old pattern (15)New approach (16)
fetch(..., { next: { revalidate: 3600 } })'use cache' + cacheLife('hours')
fetch(..., { next: { tags: ['x'] } })'use cache' + cacheTag('x')
export const revalidate = 60Wrap the cached work in 'use cache' + cacheLife
export const dynamic = 'force-static''use cache' at the page level
revalidateTag('x')revalidateTag('x', 'max') or updateTag('x')
experimental.ppr / experimental_pprRemoved — cacheComponents: true

The biggest shift is mental: in 15, you opted out of caching; in 16, you opt in. Start with everything dynamic, then cache deliberately where you've measured a benefit.

Testing Your Implementation

Verify the behavior, don't assume it:

  1. Confirm the static shell. Run next build and check the build output — your route should report a prerendered shell. If Next.js errors about missing Suspense boundaries, add them where you read dynamic data.
  2. Watch the cache warm. In dev, log a timestamp inside a 'use cache' function. The first request runs it; subsequent requests within the cacheLife window reuse the value (timestamp stays frozen).
  3. Test invalidation. Trigger your Server Action and confirm updateTag shows fresh data on the same request, while revalidateTag serves stale-then-fresh.
  4. Check streaming. Throttle your network in DevTools — you should see the shell paint first, then dynamic sections fill in.

Troubleshooting

"Route couldn't be prerendered" build error. A component reads dynamic data (cookies, headers, or an uncached fetch) outside a Suspense boundary. Wrap it in <Suspense> or cache it with 'use cache'.

My cached data never updates. Check your cacheLife profile and confirm you're invalidating the right tag. Remember revalidateTag now needs a profile argument — the single-argument form silently does less than you expect.

Changes don't show after a form submit. You're likely using revalidateTag where you need updateTag. For read-your-writes inside a Server Action, updateTag is the correct primitive.

params is undefined. In Next.js 16, params and searchParams are Promises. Await them.

Next Steps

Conclusion

Cache Components is the clearest caching story Next.js has ever shipped. Instead of guessing what the framework decided to cache, you declare it: 'use cache' to opt in, cacheLife to set freshness, cacheTag to mark for invalidation, and the right one of revalidateTag / updateTag / refresh to invalidate precisely. Pages render dynamic by default, you cache deliberately, and a static shell streams dynamic content for the best of both worlds. Start with one route, prove the model, and roll it out from there.


Sources: Next.js 16 release blog, cacheComponents config reference.