Caching in Next.js 16: a practical guide to Cache Components

Cache Components makes caching explicit with "use cache", cacheLife, and Suspense. Here's how the pieces fit together, and the build errors you'll meet along the way.

Caching in the Next.js App Router used to be implicit, and implicit caching produced a familiar class of bugs: pages that were static when you expected them to be dynamic, and the other way around. With Cache Components, Next.js 16 flips the default. Nothing is cached unless you say so, and the build fails if you access dynamic data without deciding how it should render.

This site runs on it, so here's the practical version of how it works.

Turning it on

Cache Components is enabled in next.config.ts:

import type { NextConfig } from "next"

const config: NextConfig = {
  cacheComponents: true
}

export default config

From then on, every route is prerendered as much as possible, and you mark what may be cached and for how long.

Marking cached work with "use cache"

The "use cache" directive marks a function, a component, or a whole file as cacheable. Its return value is cached, keyed by its arguments. cacheLife sets how long:

"use cache"

import { cacheLife } from "next/cache"

export async function getRepository(name: string) {
  cacheLife("hours")

  const response = await fetch(`https://api.github.com/repos/${name}`)

  if (!response.ok) {
    return undefined
  }

  return response.json()
}

Built-in profiles like "hours" cover most needs. Pick the lifetime from how stale the data is allowed to be, not from how expensive it is to fetch.

Components work the same way. A cached component can be part of a static page and still refresh on its own schedule:

async function Year() {
  "use cache"

  cacheLife("hours")

  return <time>{new Date().getFullYear()}</time>
}

Dynamic data must be cached or wrapped in Suspense

This is the rule that catches everyone at first. When Next.js prerenders a route and hits data it can't cache, such as the current time, an uncached fetch, or request-specific values, it needs to know what to do. You have two choices:

  1. Cache it with "use cache", accepting some staleness.
  2. Wrap it in <Suspense>, so the surrounding page is prerendered and that part streams in at request time.
import { Suspense } from "react"

export default function Page() {
  return (
    <>
      <Introduction />
      <Suspense fallback={<SongsSkeleton />}>
        <LatestSongs />
      </Suspense>
    </>
  )
}

If you do neither, the build fails and points at the component. That's annoying the first time and very useful afterwards: rendering behavior is a decision you make in code, not a side effect you discover in production.

generateStaticParams needs at least one result

Dynamic routes have one more rule. With Cache Components enabled, generateStaticParams must return at least one entry, so Next.js can validate the route at build time. An empty array fails the build with EmptyGenerateStaticParamsError.

That's easy to hit with content that doesn't exist yet, like a blog with no published posts. A placeholder that renders a 404 keeps the build working until real entries arrive:

import { notFound } from "next/navigation"

const PLACEHOLDER_SLUG = "_"

export function generateStaticParams() {
  const posts = getPosts()

  return posts.length > 0
    ? posts.map(({ slug }) => ({ slug }))
    : [{ slug: PLACEHOLDER_SLUG }]
}

export default async function Page({
  params
}: {
  params: Promise<{ slug: string }>
}) {
  const post = getPost((await params).slug)

  if (!post) {
    notFound()
  }

  // ...
}

Invalidating on demand

Time-based lifetimes are enough for a lot of content, but some data should refresh the moment it changes, like a product price or a published post. Tag the cached entry with cacheTag:

"use cache"

import { cacheLife, cacheTag } from "next/cache"

export async function getProduct(id: string) {
  cacheLife("days")
  cacheTag(`product-${id}`)

  return db.product.findUnique({ where: { id } })
}

Then invalidate that tag where the change happens. Next.js 16 gives you two functions for this, and the difference matters:

"use server"

import { updateTag } from "next/cache"

export async function updatePrice(id: string, price: number) {
  await db.product.update({ where: { id }, data: { price } })

  // The user who made the change sees the new price immediately
  updateTag(`product-${id}`)
}

updateTag only works inside Server Actions and gives read-your-own-writes behavior: the next read waits for fresh data. For changes that arrive from outside, like a webhook from your CMS, use revalidateTag in a Route Handler. It now takes a cache profile as its second argument, and with "max" it serves the existing entry while fresh data is fetched in the background:

import { revalidateTag } from "next/cache"

export async function POST(request: Request) {
  const { productId } = await request.json()

  revalidateTag(`product-${productId}`, "max")

  return Response.json({ revalidated: true })
}

Long lifetimes plus targeted invalidation give you fast pages without stale data.

What I'd tell a team adopting it

  • Start from the build errors. They list every place where rendering behavior was previously implicit. Fixing them is mostly deciding "cache this" or "stream this".
  • Keep cached functions pure. Anything a cached function reads that isn't an argument, like the current user, will leak between requests or break caching. Pass it in explicitly.
  • Put Suspense boundaries where users can tolerate a loading state, not around the whole page.
  • Prefer tags over short lifetimes for data that changes on known events, using updateTag in Server Actions and revalidateTag everywhere else.

The result is a mental model that fits in a paragraph: everything is static by default, "use cache" opts data into reuse, and Suspense marks what's truly dynamic.