---
title: "Partial prerendering"
description: "Storing what every visitor sees the same, and rendering the rest per request."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.rsc-kit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Partial prerendering

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

```text
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.

```tsx title="src/app/posts/[slug]/page.tsx"
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:

```tsx title="src/app/posts/[slug]/loading.tsx"
export default function Loading() {
  return <div className="h-6 w-50 animate-pulse rounded-lg bg-zinc-800" />;
}
```

```tsx title="src/app/posts/[slug]/page.tsx"
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:

```text
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:

```text
◐  /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:

```text
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.

Source: https://docs.rsc-kit.dev/guides/ppr/index.mdx
