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 parsesPostponed, 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 urlsawait searchParams— the query stringawait 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.
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:
export default function Loading() {
return <div className="h-6 w-50 animate-pulse rounded-lg bg-zinc-800" />;
}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.tsxA 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.