Streaming, Suspense and partial prerendering
Instant loading UI, streaming server components with Suspense boundaries, skeletons that match the layout, and a static shell with dynamic holes.
loading.tsx and Suspense boundaries
// app/guides/loading.tsx — the whole segment gets an instant fallback
export default function Loading() {
return <Skeleton rows={5} />
}import { Suspense } from 'react'
export default function GuidePage() {
return (
<main>
<h1>Guides</h1>
<Suspense fallback={<Skeleton rows={3} />}>
<SlowGuideList />
</Suspense>
<Suspense fallback={<p>Loading stats...</p>}>
<Stats />
</Suspense>
</main>
)
}loading.tsxis sugar for a Suspense boundary around the segment's page, so the route renders immediately while the data resolves.- Wrap the slow part, not the page. A boundary around everything makes the user wait for the slowest query before seeing anything at all.
- Boundaries are independent: two slow sections in two boundaries appear as each finishes, rather than together when the last one lands.
- The fallback is part of the first HTML, so it must not itself depend on data. A fallback that fetches defeats the point of having one.
- An async server component starts streaming at its first
await, so slow work belongs below the boundary, not above it.
Streaming server components
// start the request, pass the promise down, await it where it is needed
async function GuideList({ guidesPromise }) {
const guides = await guidesPromise // suspends here; the shell has already been sent
return <ul>{guides.map(g => <li key={g.id}>{g.title}</li>)}</ul>
}
export default function Page() {
const guides = getGuides() // do not await: a request is a promise
return (
<>
<Header />
<Suspense fallback={<Skeleton />}>
<GuideList guidesPromise={guides} />
</Suspense>
</>
)
}- Passing a promise down instead of awaiting it starts the work immediately and makes only the slow section wait.
- Sequential awaits inside one component create a waterfall. Run independent queries with
Promise.all, or start each one before the boundary. - The HTML arrives in pieces over the same response, so the browser can paint the shell before the last query resolves.
- Streaming only helps when the shell is meaningful. A header plus a spinner beats a blank page, but a skeleton shaped like the final layout is better.
- An error inside a boundary is caught by the nearest
error.tsx, so one failing section does not take down the whole page.
⚠️
A Suspense boundary does not make slow code fast. If everything is inside a boundary, you have only moved the wait around. Fix the query, add an index or cache the result before reaching for more boundaries.
A static shell with dynamic holes
import { Suspense } from 'react'
import { cookies } from 'next/headers'
export default function ProductPage({ params }) {
return (
<div>
<ProductDetails params={params} /> {/* prerendered at build time */}
<Suspense fallback={<CartSkeleton />}>
<CartCount /> {/* per-user, rendered on request */}
</Suspense>
</div>
)
}
async function CartCount() {
const session = (await cookies()).get('session') // dynamic: stays below the boundary
return <span>{session ? await countItems(session.value) : 0}</span>
}- Partial prerendering combines the two models: a static shell is served from the CDN and only the personalised holes are rendered per request.
- Placement is what makes it work. A dynamic API called in the shell turns the whole route dynamic; called inside the boundary, only that subtree is.
- The shell is the fastest possible first byte for every visitor, and the server cost is limited to the small dynamic region.
- Keep the hole small. A boundary wrapped around the entire page removes the benefit and leaves you with ordinary dynamic rendering.
- The build output reports these routes differently from fully static or fully dynamic ones, which is how you confirm the split is what you intended.
FAQ
When should I not stream?
When the whole response is cheap, when the page is tiny, or when the data must be present before the layout can be sized — a streamed table that changes column widths as it fills is worse than waiting. Streaming pays off when one slow section sits beside fast content.
Is loading.tsx enough?
It is enough when a single page load dominates the route. Reach for explicit Suspense boundaries when several sections load independently and you want them to appear as they finish rather than together.
Related
Dynamic routes, params and metadata Styling, fonts and image optimisation
Last refreshed 2026-09-18.