Skip to content
PS

11 July 2026 ·

Cache Components and PPR in Next.js 16: what changed and how I use it


For a couple of years the Next.js caching story was a pile of overlapping experimental flags — experimental.ppr, experimental.useCache, experimental.dynamicIO — plus route-segment configs like export const revalidate and the unstable_cache function. You could make it work, but explaining why it worked to a client's junior dev was painful.

Next.js 16 collapses all of that into one flag: cacheComponents. This is the mental model I wish I'd had earlier, and how I actually use it on real sites.

The one idea: dynamic by default, cache on purpose

With Cache Components enabled, data fetching is dynamic by default. Nothing is cached unless you say so. You then opt specific pages, components, or functions into caching with the use cache directive.

Next.js prerenders a static HTML shell and serves it immediately, then streams the dynamic parts in when they're ready. Mixing static and dynamic within a single route — that's Partial Prerendering (PPR), and in Next 16 it's just the default behaviour of Cache Components, not a separate flag you toggle.

So the model is two questions per piece of UI:

  1. Can this be the same for everyone for a while? → cache it, it goes in the static shell.
  2. Is this per-request or per-user? → leave it dynamic, it streams into a hole.

Turning it on

// next.config.ts
import type { NextConfig } from "next";

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

export default nextConfig;

That single flag replaces the old experimental.ppr, experimental.useCache and experimental.dynamicIO. If you were on any of those, the Next 16 upgrade guide has the migration path.

use cache — three levels

The directive works at file, component, or function level:

// Whole file cached
"use cache";

export default async function Page() {
  // ...
}
// Single component cached
export async function PriceList() {
  "use cache";
  const prices = await getPrices();
  return <ul>{/* ... */}</ul>;
}
// Just the data function cached
export async function getProducts() {
  "use cache";
  return db.query.products.findMany();
}

Function level is what I reach for most: cache the expensive data access, keep the components around it free to stay dynamic.

cacheLife — how long is "a while"

cacheLife sets the revalidation profile for a cached scope. There are named profiles, and you can define your own:

import { unstable_cacheLife as cacheLife } from "next/cache";

export async function getBlogPosts() {
  "use cache";
  cacheLife("days"); // blog content that updates daily
  return db.query.posts.findMany();
}

Pick the profile that matches how stale the data is allowed to be. Marketing copy? days. A product catalogue? hours. Don't reach for on-demand invalidation until a time window genuinely can't express the requirement.

cacheTag + invalidation — and the revalidate vs update distinction

Tag a cached entry so you can invalidate it precisely when the underlying data changes:

import { unstable_cacheTag as cacheTag } from "next/cache";

export async function getProduct(id: string) {
  "use cache";
  cacheTag(`product-${id}`);
  return db.query.products.findFirst({ where: eq(products.id, id) });
}

Then, when something changes, you have two invalidation functions and they are not interchangeable:

  • revalidateTag(tag) — use it in Route Handlers, webhooks, or anywhere. It marks the tag stale; the next request refreshes it.
  • updateTag(tag) — Server Actions only. It's built for read-your-own-writes: it immediately expires the tag and the next render waits for fresh data, so the user who just created something sees their change, not a stale copy.

The rule I use: a webhook from the CMS calls revalidateTag; a user editing their own data in a Server Action calls updateTag.

"use server";
import { updateTag } from "next/cache";

export async function updateProduct(id: string, data: FormData) {
  await db.update(/* ... */);
  updateTag(`product-${id}`); // this user sees the edit immediately
}

The gotcha that will bite you: cookies and headers

A cached scope can't read request-time data like cookies() or headers() — that would make "cached" meaningless. The intended pattern is to read them outside the cached scope and pass the values in as arguments:

// page.tsx (dynamic)
import { cookies } from "next/headers";
import { getDashboard } from "./data";

export default async function Page() {
  const region = (await cookies()).get("region")?.value ?? "eu";
  return <Dashboard promise={getDashboard(region)} />;
}
// data.ts
export async function getDashboard(region: string) {
  "use cache";
  cacheTag(`dashboard-${region}`);
  // region came in as an argument — cacheable per region
}

If you genuinely can't refactor to pass values in, there's use cache: private for per-user caching, and use cache: remote when the in-memory cache isn't enough and your platform provides a dedicated handler. Reach for those last.

How I actually structure a page

The pattern that's become my default for a content-plus-personalisation page:

  • Static shell: header, hero, marketing sections — cached, prerendered, served from the edge instantly.
  • Dynamic holes in <Suspense>: the "recently viewed", the cart count, anything per-user — streamed in.
export default async function Page() {
  return (
    <>
      <MarketingSections /> {/* "use cache" — in the shell */}
      <Suspense fallback={<CartSkeleton />}>
        <CartSummary /> {/* dynamic — streams in */}
      </Suspense>
    </>
  );
}

The visitor sees a complete-looking page immediately, and the personalised bits fill in a beat later. That's the whole promise of PPR, and now it's the default rather than a flag you have to justify.

Migrating from the old world

If you're coming from Next 15 or earlier:

  • export const revalidate = 3600 on a segment → move the intent into a use cache scope with cacheLife.
  • unstable_cache(fn, keys, { tags }) → a function with use cache + cacheTag.
  • experimental.ppr / experimental_ppr route config → gone; cacheComponents gives you PPR by default.

The "Migrating to Cache Components" guide in the Next docs walks the mechanical parts. The conceptual shift is the part worth internalising: you stopped declaring what's dynamic and started declaring what's cached.

When I don't bother

Cache Components shines when a route mixes stable and per-user content. For a fully static marketing page it's overkill — plain static rendering is simpler. For a fully dynamic dashboard where nothing is shareable, you're mostly leaving things dynamic anyway. The sweet spot is the messy middle, which happens to be where most real client sites live.