Skip to content

Partial prerendering

Storing what every visitor sees the same, and rendering the rest per request.

Updated View as Markdown

A page usually has two halves: markup that is the same for everybody, and data that is not. The build stores the first and renders the second per request, so the browser paints immediately and fills in as the data arrives.

There is nothing to switch on, and no mode to pick. Every route goes through the same probe, and this is one of the things it can find.

How the build decides

Build time
  → the page is rendered with a budget
  → anything still waiting when the budget expires is postponed
  → React has already flushed what did not need the request: layouts, static
    markup, and the Suspense fallbacks standing in for the rest
  → that markup is stored as the route's shell, and beside it, where it stopped
Request time
  → the shell is served straight from disk — fast, no render
  → the render is picked up from where it stopped, against data that exists now
  → only the unfinished boundaries are written, onto the same response
  → a script React emits beside each one moves it into place as the HTML parses

Postponed, not merely abandoned. The difference matters. Aborting a render gives you the bytes that flushed and nothing else — React has no record of where it got to, so the holes can only be filled later by the browser. Postponing keeps that record, which is what lets the boundaries be finished at the origin and arrive with the document.

So the content is in the first response. It appears without waiting for the app bundle or for hydration — on a slow connection, the difference between a spinner and a page — and it is in the HTML a crawler reads.

This is not the same as working with scripting off: with JavaScript disabled the fallbacks stay.

If anything goes wrong — the engine cannot resume, or the render fails — the shell is served on its own and the client fills the boundaries when it hydrates, exactly as it did before any of this existed. A slower hole, never a wrong page.

The shell is stored per route pattern, not per url. /posts/[slug] has one shell serving every slug, because everything that varies by slug is behind a boundary the request fills.

What makes a page dynamic

Anything the build cannot know:

  • await params — the url, for a route that has not listed its urls
  • await searchParams — the query string
  • await headers() / await cookies() — the request
  • a host call, on a host that has one
  • slow work of your own that outlasts the budget

All of them behave the same way: the read suspends, the nearest fallback above it goes into the shell, and the real value arrives per request.

Where to put the boundaries

Only content inside a <Suspense> boundary can stream. Content above every boundary has to finish before anything paints — which is the difference between a route the build can store and one it refuses.

src/app/posts/[slug]/page.tsxtsx
import { Suspense } from 'react';
import { findPost } from '../../../data';

async function Body({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await findPost(slug);

  return <article><h1>{post.title}</h1><p>{post.body}</p></article>;
}

export default function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Body params={params} />
    </Suspense>
  );
}

Body is still waiting when the budget expires, so the shell holds the fallback. At request time the slug resolves and React replaces it.

loading.tsx instead

Rather than wrapping every page by hand, put a loading.tsx beside it. The build wraps the page in <Suspense fallback={<Loading />}> for you, and the page can await at its top level:

src/app/posts/[slug]/loading.tsxtsx
export default function Loading() {
  return <div className="h-6 w-50 animate-pulse rounded-lg bg-zinc-800" />;
}
src/app/posts/[slug]/page.tsxtsx
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;

  return <h1>{(await findPost(slug)).title}</h1>;
}

It is hierarchical

Like layout.tsx, the nearest loading.tsx to the page wins, and several in one chain stack as nested boundaries:

src/app/
  layout.tsx
  loading.tsx           fallback for everything without a closer one
  docs/
    loading.tsx         fallback for /docs/*
    [slug]/
      page.tsx          uses docs/loading.tsx
  dashboard/
    page.tsx            uses app/loading.tsx

A root loading.tsx catches everything, including mistakes

It wraps every page, so a page that waits above any boundary of its own is still caught and still stored — and the build has nothing to refuse. It looks exactly like a page whose boundary is in the right place.

So the build says so:

◐  /locale
   ⚠  nothing painted without the root loading.tsx — the fallback the whole app
      shares is standing in for this page. Put a boundary where the waiting is.

Worked out by rendering the route a second time without the root fallback: a page with a boundary of its own paints immediately, and a page leaning on the root paints nothing. Only routes with no closer loading.tsx are asked, so it costs nothing for the rest.

It is a warning rather than an error because the page does work. What it costs is shared: every page in the app shows the same fallback while this one waits, and the fallback cannot say anything about what is loading. Moving the boundary to where the waiting is fixes both.

When nothing can be frozen

If nothing was flushed before the page blocked, there is no shell to store — and the build says so rather than filing the route under a category:

Some routes could not be prerendered:

  /dashboard — reaches for the host before anything can paint, so there is no shell to store

Each one reads request data — params, headers, cookies, or the host —
above every Suspense boundary, so nothing can paint without it.

Put the part that waits inside <Suspense>, or add a loading.tsx beside
the page, so there is something to store while the rest arrives.

Fixed the same way every time, and there is no way to declare it away. A route the build cannot store has its boundary in the wrong place; moving the boundary is the fix.

If the build hangs

RSC_PPR_TIMEOUT_MS sets it; the default is 2 seconds. Raising it lets slow pages be stored whole; lowering it pushes more of them to shells.

A page whose data resolves inside the budget is stored whole, even one that called fetch(). If a page must reflect the world at request time, what makes it so is reading the request — not a flag.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close