# Next.js 16 Cache Components: PPR Finally Makes Sense
By Govind Bajaj · 2026

Tags: next.js, react, performance, ppr, server-components
I fought with Partial Prerendering for a year. Then Cache Components landed in Next.js 16 and the mental model clicked. Here's how to actually use it.
I spent six months fighting with Partial Prerendering in Next.js 14 and 15.

Every time I enabled `experimental_ppr`, something broke. Dynamic IO would throw cryptic errors about uncached data outside Suspense boundaries. My build logs filled with warnings I didn't understand. I eventually turned it off and went back to forcing static or dynamic per route like everyone else.

Then Next.js 16 shipped Cache Components. And the mental model finally clicked.

## The Problem with the Old PPR

Partial Prerendering sounded simple in theory: prerender a static shell at build time, stream dynamic content per request inside Suspense boundaries. In practice, it meant opting in per route, fighting `dynamicIO`, and debugging errors like `Uncached data was accessed outside of <Suspense>`. The developer experience felt unfinished because it was.

The core issue was that PPR, dynamicIO, and `unstable_cache` were three separate systems trying to solve related problems. They overlapped in confusing ways. You needed to know which one to use when, and they didn't compose cleanly.

## Cache Components: One Flag, One Model

Next.js 16 subsumes all of that into a single coherent system. You enable it once:

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

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig
```

That's it. No per-route opt-ins. No experimental flags. PPR becomes the default behavior for every route.

The mental model is now this: every Server Component is either cached or dynamic. Cached components render at build time (or during ISR revalidation) and are served from the CDN. Dynamic components render per request and stream through Suspense boundaries. The framework figures out which is which based on what your component actually does.

## How It Actually Works

If a Server Component reads cookies, headers, or search params, Next.js automatically marks it as dynamic. Everything else defaults to cached. You don't need to think about it.

```tsx
// This is automatically CACHED — no dynamic data access
async function ProductGrid() {
  const products = await getProducts() // cached fetch
  return <div>{/* ... */}</div>
}

// This is automatically DYNAMIC — reads cookie
async function UserBadge() {
  const session = await auth() // dynamic: reads cookie
  return <span>{session?.user?.name}</span>
}
```

The key insight: **you don't opt into PPR anymore. You write normal components, and the framework does the partial prerendering automatically.**

## The Suspense Boundary Rule Still Matters

Dynamic components must be wrapped in Suspense. If they're not, Next.js can't stream the static shell separately from the dynamic content. The difference is that now you get clear, actionable errors instead of cryptic build warnings.

```tsx
import { Suspense } from 'react'

export default function Page() {
  return (
    <div>
      <ProductGrid /> {/* cached at build time */}
      
      <Suspense fallback={<Skeleton />}>
        <UserBadge /> {/* dynamic, streamed per request */}
      </Suspense>
    </div>
  )
}
```

## Caching Data, Not Just Components

The `use cache` directive (stable in Next.js 16) lets you cache any async function, not just full components. This is where the `unstable_cache` replacement comes in.

```tsx
'use cache'

export async function getProducts() {
  const products = await db.query.products.findMany()
  return products
}
```

You can also set cache tags for fine-grained revalidation:

```tsx
'use cache'

export async function getProduct(id: string) {
  'use cache'
  cacheTag(`product-${id}`)
  
  const product = await db.query.products.findFirst({
    where: { id }
  })
  return product
}
```

Revalidate by tag from anywhere:

```ts
import { revalidateTag } from 'next/cache'

// After updating a product
await revalidateTag(`product-${productId}`)
```

## Migration from PPR Experimental

If you were using `experimental_ppr` before, migration is straightforward:

1. Remove `experimental_ppr: true` from route configs
2. Enable `cacheComponents: true` in `next.config.ts`
3. Replace `unstable_cache` with `'use cache'`
4. Remove `dynamicIO` — it's subsumed into the new system

## What I'd Do Differently Now

When I rebuilt Sans Herbals with Cache Components, I deleted about 200 lines of cache configuration. The ProductGrid, category filters, and static content all just worked. The cart count and user-specific pricing streamed in via Suspense.

The old mental model was: "Should I cache this?" The new mental model is: "This will be cached unless it accesses dynamic data." That inversion is subtle but transformative.

## Takeaways

- Cache Components in Next.js 16 replace three overlapping systems with one coherent model
- PPR becomes the default — no per-route opt-in required
- `'use cache'` replaces `unstable_cache` with a cleaner API
- Suspense boundaries are still required for dynamic content, but errors are now clear
- If you abandoned PPR before, try again in v16 — the DX is fundamentally different