Cache Components: PPR Grew Up

October 8, 2026

A couple of years ago, I wrote about how Partial Prerendering was going to change the way we think about rendering in Next.js. Static rendering was fast, dynamic rendering was flexible, and Partial Prerendering gave us both.

Well... it looks like PPR grew up. Next.js 16.4 is out, and the biggest change isn't actually another rendering strategy. It's a new programming model called Cache Components.

Cache Components turns PPR into an explicit model: choose what gets cached, how long it lives, and whether it runs during the initial render, prefetch, or navigation.

Let's go back for a second

If you haven't read my previous post on PPR, here's the basic problem.

Historically, we had two main ways to render a page.

Static rendering

Build the HTML ahead of time.

export default async function BlogPost() {
  const post = await getPost()

  return <Post post={post} />
}

The result can be generated ahead of time and served from a CDN. Very fast. But what happens if I need the current user's information?

const user = await getCurrentUser()

Now I need request-time data, and that's where dynamic rendering comes in.

Dynamic rendering

Build the HTML when the request arrives.

export default async function DashboardPage() {
  const user = await getCurrentUser()

  return <Dashboard user={user} />
}

Now I can access cookies, authentication, request headers, databases, whatever I need. But I also have to do work when the user requests the page. Not ideal.

Then PPR came along.

Partial Prerendering

The idea behind PPR was beautifully simple: why does the entire page have to be static or dynamic? It doesn't.

Imagine this:

export default function Page() {
  return (
    <>
      <Header />
      <BlogPost />

      <Suspense fallback={<AvatarSkeleton />}>
        <CurrentUserAvatar />
      </Suspense>
    </>
  )
}

The blog post doesn't care who you are, but the avatar does. So why should we render them the same way? With PPR, Next.js could prerender the static parts of the page and leave a hole for the dynamic part.

┌──────────────────────────────────────┐
│ Header                               │
│                                      │
│ Blog post                            │
│                                      │
│ ┌──────────────────────────────────┐ │
│ │       Avatar skeleton            │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────┘

The static shell could be served immediately, then the dynamic part could be rendered at request time and streamed into the page.

And this is important because I've seen some of the discussion around Cache Components make it sound like Next.js only just learned how to mix static and dynamic components. It didn't. PPR already did that. What changed is the caching model around it.

So what are Cache Components?

Next.js 16.4 introduces Cache Components as the recommended programming model for new applications, and it'll become the default in Next.js 17. New apps created with create-next-app already have it enabled.

The simplest way to think about it is:

'use cache' is basically a cache boundary for a component or function.

For example:

import { cacheLife } from 'next/cache'

async function Projects({ userId }) {
  'use cache'

  cacheLife('hours')

  const projects = await db.project.findMany({
    where: { userId },
  })

  return (
    <div>
      {projects.map((project) => (
        <p key={project.id}>{project.name}</p>
      ))}
    </div>
  )
}

I just told Next.js: cache this. And that's a pretty big deal, because this isn't necessarily a fetch().

It could be a database query, an expensive computation, an API call, or an entire React component. The cache boundary is now much closer to the thing I'm actually trying to cache.

Next.js describes 'use cache' as essentially a component-level version of the HTTP Cache-Controlheader, and that's a pretty good mental model.

But wasn't this already possible?

Yes, and this is where things get interesting. Before Cache Components, Next.js had a bunch of different caching mechanisms.

The old model exposed all of these options:

fetch(url, { cache: 'force-cache' })
fetch(url, { cache: 'no-store' })
export const revalidate = 60
export const dynamic = 'force-dynamic'
unstable_cache(...)

And then you had the Data Cache, Full Route Cache, Router Cache, and PPR on top of all of that. It worked, but it wasn't exactly something I wanted to explain to someone over coffee.

The problem wasn't that Next.js couldn't cache things. The problem was that caching behavior was sometimes implicit, so Next.js could infer what a route should do based on what happened during rendering.

For example, historically, request-specific APIs could influence whether a route was dynamically rendered.

So you could have:

Product data        cached
User data           dynamic

          ↓

Route behavior      dynamic

Even though only one part of your page actually needed the request.

PPR helped solve the rendering side of this. Cache Components takes another step and says: let's make caching explicit.

Next.js explicitly describes Cache Components as replacing the implicit caching behavior of previous App Router versions.

The new mental model

Instead of asking, “Is this page static or dynamic?”, we can ask, “Which parts of this page should be cached?”

export default async function Page() {
  const user = await getCurrentUser()

  return (
    <>
      <Header />

      <BlogPost />

      <Suspense fallback={<Loading />}>
        <Recommendations userId={user.id} />
      </Suspense>
    </>
  )
}

The blog post and recommendations declare their cache boundaries:

async function BlogPost() {
  'use cache'

  return <Post />
}
async function Recommendations({ userId }) {
  'use cache'

  return <RecommendationsList userId={userId} />
}

Meanwhile, the current user remains dynamic:

async function getCurrentUser() {
  // request-time
}

So our page becomes:

                PAGE
                  │
       ┌──────────┼──────────┐
       │          │          │
       ↓          ↓          ↓
    Header     BlogPost   Recommendations
                  │          │
               cached      cached
                  │          │
                  └────┬─────┘
                       │
                  CurrentUser
                       │
                    dynamic

This looks suspiciously similar to PPR because it is. Cache Components makes PPR part of the broader model: cached and request-time content can share one response, with cached pieces prerendered and dynamic pieces rendered at request time. The difference is that caching is now a first-class, explicit primitive.

What actually became easier?

This is probably the most important question. The new model makes a few things significantly easier.

1. Caching arbitrary server work

Previously, if I wanted to cache something that wasn't simply a fetch, I had to reach for unstable_cache, Next.js's escape hatch for arbitrary server-side work.

fetch already knew how to be cached:

fetch(url, {
  next: { revalidate: 3600 },
})

A database query, a Stripe call, or some internal API didn't.

So you wrapped the function:

import { unstable_cache } from 'next/cache'

const getProjects = unstable_cache(
  async (userId) => {
    return db.project.findMany({
      where: { userId },
    })
  },
  ['projects'],
  {
    revalidate: 3600,
    tags: ['projects'],
  }
)

Then:

const projects = await getProjects(userId)

Next.js cached that result, so you didn't hit the database every time. A revalidate time and tags let you expire it, or invalidate it on demand.

The awkward part was everything around the work. The cache key, the lifetime, the tags, where the wrapper lived, and how it interacted with PPR and the other caches.

Now I can do:

async function getProducts() {
  'use cache'

  return db.products.findMany()
}

That's much easier to reason about because the cache is sitting right next to the work being cached.

And because this can be used around arbitrary server-side work, I don't need to mentally translate:

“I need this database operation cached”

into:

“Which Next.js cache API do I need to wrap this in?”

I can just say:

'use cache'

2. You can explicitly control cache lifetime

You can also tell Next.js how long something should live.

import { cacheLife } from 'next/cache'

async function BlogPosts() {
  'use cache'

  cacheLife('hours')

  const posts = await getPosts()

  return <Posts posts={posts} />
}

So now I can look at the function and immediately understand:

getPosts()

  ↓

'use cache'

  ↓

cache for hours

No detective work required. 'hours'isn't a policy I made up; it's a built-in Next.js profile. I'm telling Next to use the predefined caching policy called hours.

There are others too, like seconds, minutes, days, and weeks. If I want the exact behavior, I can pass it myself:

cacheLife({
  stale: 60,
  revalidate: 300,
  expire: 3600,
})

Now I'm deciding how the cached value should behave. How long it can stay stale, when it should revalidate, and when it expires.

3. Dynamic is the fallback, caching is explicit

This is one of the biggest philosophical changes. With Cache Components, code that isn't marked with 'use cache' can remain request-time.

If I write:

async function Page() {
  const user = await getCurrentUser()

  return <Dashboard user={user} />
}

I don't need to tell Next:

export const dynamic = 'force-dynamic'

The request-time work is just request-time work. If I want something cached, I explicitly say:

async function Products() {
  'use cache'

  // ...
}

I actually like this model a lot more. It follows a pretty intuitive rule:

Don't cache my code unless I asked you to.

And then:

If I know something can be cached, I'll mark it as cacheable.

4. PPR becomes part of the default model

This is probably the biggest change if you've been following PPR since it was experimental. Back when I wrote about PPR, you had to think about enabling an experimental rendering model and deciding where it applied. Now the story is much simpler.

Cache Components and partial prefetching work together as one model, and PPR is effectively the mechanism that lets the cached and dynamic pieces come together in a single response.

So instead of:

Next.js
  │
  ├── Static rendering
  │
  ├── Dynamic rendering
  │
  └── Experimental PPR

we're moving toward:

Next.js
  │
  └── Cache Components
          │
          ├── cached
          ├── prerendered
          └── request-time

That's a much cleaner model.

There's one thing I REALLY like about 16.4

You can now tell Next.js, “This route better stay static.”Suppose I have a blog. I don't want someone adding a random dynamic component six months from now and accidentally turning an optimized page into something that needs request-time computation.

I can now do:

export const ensureStatic = 'navigation'

export default function BlogPost() {
  return (
    <>
      <Header />
      <Post />
      <Comments />
    </>
  )
}

Now Next.js will fail if dynamic content sneaks into the route. That's a genuinely useful production guardrail.

The other levels are prefetch and shell:

export const ensureStatic = 'prefetch'
export const ensureStatic = 'shell'

shellguarantees the initial route shell is static. Next won't pull dynamic, request-time data just to build that first shell.

prefetch guarantees an explicit prefetch only fetches static, cacheable content. Dynamic stuff waits until an actual navigation.

navigation is the strongest guarantee. The entire navigation has to stay static, or the build fails.

You can also put the setting on a layout to apply the guarantee to everything underneath it.

So I can essentially tell Next:

"Hey Next, I spent a lot of time making this site fast.

Please don't let someone accidentally ruin it."

Thank you.

And then we get to prefetching

This is another part of 16.4 that I think is easy to overlook. Imagine Gmail with an inbox showing 5 messages:

{messages.map((message) => (
  <Link key={message.id} href={`/message/${message.id}`} prefetch>
    {message.subject}
  </Link>
))}

Let's say every message has a cached thread. If I prefetch every visible link, Next.js could end up doing this:

Inbox
 │
 ├── Message 1 → fetch entire thread
 ├── Message 2 → fetch entire thread
 ├── Message 3 → fetch entire thread
 ├── Message 4 → fetch entire thread
 └── Message 5 → fetch entire thread

That's great if the user clicks every message, but they won't. I probably just made my database work five times harder for absolutely no reason.

So Next.js 16.4 introduces:

import { navigation } from 'next/cache'

async function Message({ id }) {
  const message = await getMessage(id)

  return (
    <>
      <p>{message.subject}</p>
      <div>{message.body}</div>

      <Suspense fallback={<Spinner />}>
        <Thread id={id} />
      </Suspense>
    </>
  )
}

async function Thread({ id }) {
  'use cache'

  await navigation()
  const thread = await getThread(id)

  return (
    <>
      {thread.map((message) => (
        <div key={message.id}>{message.body}</div>
      ))}
    </>
  )
}

navigation() tells Next.js to defer that cached work until an actual navigation happens instead of doing it during prefetch.

Now I can prefetch the cheap stuff:

Message page
│
├── Subject       ← prefetch
├── Body          ← prefetch
└── Thread        ← wait

And once the user actually clicks:

Message page
│
├── Subject       ← already here
├── Body          ← already here
└── Thread        ← load now

This is a really nice addition because it gives us control over when work happens, not just whether it happens.

prefetch() does the opposite

There's also a new prefetch() API. You can use it when you want to keep something out of the initial shell but include it when an explicit prefetch happens.

So now we have multiple stages:

Page discovered
      ↓
   shell
      ↓
explicit prefetch
      ↓
actual navigation

We can decide which pieces of work happen at each stage, which is where I think Next.js gets much more interesting.

We're no longer simply choosing:

static vs dynamic

We're choosing:

when should this computation happen?

That's a much more useful question. Next.js describes these new APIs as a way to balance eager and lazy rendering based on the needs of your application.

And this is where AI agents get interesting

The bigger change is that Next.js is finally turning all of these ideas into one coherent model, and that matters for more than developer ergonomics. It matters because we're increasingly not the only ones writing our code. AI agents are.

And one of the biggest problems with having a framework that exposes 15 different ways of doing roughly the same thing is that you now have to teach all of those concepts to an agent too.

Imagine asking an agent:

“Make this page cache for an hour, but keep the user-specific section dynamic.”

With the older model, the agent might have to reason about things like:

  • PPR
  • Suspense
  • fetch caching
  • Data Cache
  • Full Route Cache
  • unstable_cache
  • revalidate
  • dynamic
  • cookies
  • prefetching
  • Router Cache

And then figure out which combination of those concepts actually produces the behavior you want. That's a lot of surface area, and this is where I think simplicity becomes an AI feature.

If the mental model is:

'use cache'
     ↓
this thing is cached

no 'use cache'
     ↓
this thing is request-time

Then both a human and an agent have a much smaller space to reason about.

You can ask an agent:

“Cache this component for an hour.”

And when it gives you:

async function Recommendations() {
  'use cache'

  cacheLife('hours')

  const recommendations = await getRecommendations()

  return <RecommendationsList data={recommendations} />
}

You can actually look at it and understand what it did. That matters, because the easier a framework is for us to understand, the better we can prompt agents and the better we can review their output.

I think this is going to become increasingly important. A framework with a simple mental model doesn't just make developers faster; it makes the human/agent feedback loop better.

Simple framework
      ↓
Better prompts
      ↓
More predictable agent output
      ↓
Easier code review
      ↓
Better corrections
      ↓
Better application

The funny thing is...

When I wrote my PPR article in 2024, I ended it with the idea that Partial Prerendering could give us the best of both worlds: static performance and dynamic applications. I still agree. Except now PPR isn't really the feature; it's the foundation.

Let me decide what gets cached, how long it lives, and when it gets rendered. Then let Next.js put the pieces together.

More importantly, give me a model simple enough that I can explain it to an AI agent, have it build the thing, and still understand the architecture when it's done.

That's a much nicer framework. Beautiful.