Next.js route caching demystified

8 min read
Static, dynamic, partial, streaming, caching – all those fancy words, step by step.
Photo taken at the Computerspielemuseum, Berlin.

Photo taken at the Computerspielemuseum, Berlin.

The route caching paradigm of the App Router is one of its most important features. It's an implicit and opinionated system that's on by default – and unless you're familiar with its defaults, you're bound to run into trouble.

In this post, I'll try and lay out my mental model of how things generally work.

Three Modes of Rendering

The particular rendering strategy for a route is determined by how it's cached, if at all.

Routes can assume one of three modes of rendering:

  1. Static (generated at build-time)
  2. Dynamic (generated at request-time)
  3. Mixed (both of the above; partial prerendering, streaming)

Static Rendering

Let's start with the following route:

export default async function Page() {
  return (
    <div>
      <Content />
    </div>
  )
}

That's all it takes to get a page going.

Is it static though? Yes!

Next does not see a reason to render this route at request-time, so it serves the output it prerendered and cached when we built the app for deployment.

Static rendering is the default. There are escape hatches, but what are the natural causes for a route to be rendered at request-time instead?

Dynamic Rendering

Ignoring escape hatches, there are only two reasons a route would opt out of static rendering and into dynamic rendering.

Dynamic Params

First, a route could make use of dynamic path params.

export default async function Page({ params }) {
  const { slug } = await params

  return (
    <div>
      <Content slug={slug} />
    </div>
  )
}

When a request comes in with some value for slug, Next won't find any matches in the build cache, so it serves a fresh render.

But, maybe we could help out? What if we knew all the possible values for the slug?

Enter `generateStaticParams`:

export default async function Page({ params }) {
  const { slug } = await params

  return (
    <div>
      <Content slug={slug} />
    </div>
  )
}

export function generateStaticParams() {
  return [{ slug: "wow" }, { slug: "much-static" }]
}

Here, we're telling Next to prerender and cache the route output at build-time for each given slug. When a request comes in using either of those slugs, the route uses static rendering.

Dynamic APIs

The second natural cause for dynamic rendering is using information from the incoming request. For example, reading cookies.

Let's say that our route is normally public, but if you're logged-in, then we're nice enough to say hello:

export default async function Page({ params }) {
  const { slug } = await params

  // get user from auth cookie
  const user = await session()
  const greeting = user ? `Welcome, ${user.name}` : "Welcome!"

  return (
    <div>
      <h2>{greeting}</h2>
      <Content slug={slug} />
    </div>
  )
}

export function generateStaticParams() {
  return [{ slug: "wow" }, { slug: "much-slug" }]
}

This route will now always use dynamic rendering. It's only logical that reading a cookie from the incoming request to render different content means that we can no longer determine the content of the page at build-time.

Also, note that generateStaticParams has no power here. It does nothing in this situation.

So that's it? We're stuck paying the cost of re-executing the unchanging <Content/> component with every request because of one dynamic, silly h2 tag?

Yes, but also no. The features that solve for exactly this problem are experimental at the time of writing.

Mixed Rendering

What if a route didn't have to be static or dynamic, but a mix of both?

Partial Prerendering

To take advantage of partial prerendering (PPR) we first have to use 'Streaming Server Rendering' where we wrap async code with suspense boundaries.

Let's add a suspense boundary to the previous example:

export default async function Page({ params }) {
  const { slug } = await params

  return (
    <div>
      <Suspense>
        <Greeting />
      </Suspense>
      <Content slug={slug} />
    </div>
  )
}

async function Greeting() {
  // get user from auth cookie
  const user = await session()
  const greeting = user ? `Welcome, ${user.name}` : "Welcome!"

  return <h2>{greeting}</h2>
}

export function generateStaticParams() {
  return [{ slug: "wow" }, { slug: "much-slug" }]
}

Assuming PPR is enabled, how do things change?

Flow chart showing a request followed by a render operation, followed by a response and in parallel the suspense boundary is resolved which leads to the final response being streamed-in.
Left: PPR DisabledRight: PPR Enabled

With partial prerendering enabled, the initial response is served immediately with the partially prerendered result at build-time up until the suspense boundary. Note that generateStaticParams is helpful again!

When a request comes in, we get an immediate response with the prerendered content. The connection stays open while the server executes a new render of the React tree including static and dynamic code. The new output is streamed-in and replaces the initial prerendered content.

Now that's seriously impressive!

But to be honest, I was a little let down. This doesn't really avoid dynamic rendering, does it? It merely hides it, showing a temporary static version while a full dynamic render happens in the background.

The issue with dynamic rendering is that it's wasteful: it makes us pay the cost of rendering never-changing-could-be-static content over and over with every request. Just because this content doesn't make use of dynamic APIs, doesn't necessarily mean it's cheap to process.

And with PPR, we're still doing that; we're re-executing code that doesn't change anything. This isn't true static rendering.

This brings us to the final mixed rendering API to discuss, the one that ties it all together.

`use cache` with Partial Prerendering

'use cache'

I'm pretty sure PPR and use cache are designed to be used together.

  • PPR allows prerendering of content despite dynamic APIs.
  • use cache enables caching of content that doesn't use dynamic APIs.

Using one without the other feels like incomplete architecture.

Putting everything together, we reach the epitome by adding the caching directive to the <Content/> component.

async function Page({ params }) {
  const { slug } = await params

  return (
    <div>
      <Suspense>
        <Greeting />
      </Suspense>
      <Content slug={slug} />
    </div>
  )
}

async function Content({ slug }) {
  "use cache"
  return <Article slug={slug} />
}

async function Greeting() {
  // get user from auth cookie
  const user = await session()
  const greeting = user ? `Welcome, ${user.name}` : "Welcome!"

  return <h2>{greeting}</h2>
}

export function generateStaticParams() {
  return [{ slug: "wow" }, { slug: "much-slug" }]
}

What do we have now?

  • Prerender at build-time? Check.
  • Immediate static response? Check.
  • Streaming dynamic content? Check.
  • Skipping static content recomputation? Check.

The holy grail of rendering optimizations, pretty much!

$ next build Route (app) ┌ ○ / ├ ○ /_not-found └ ◐ /[slug] ├ /[slug] ├ /wow └ /much-slug ○ (Static) prerendered as static content ◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
Discuss on Bluesky