Streaming is the default in the App Router. You do not turn it on; you decide where it happens. Every <Suspense> boundary is a point where the server can send what it has and fill in the rest later. Placing those boundaries well is most of the work, and with Cache Components enabled, Next.js will tell you at build time when you have not placed one.
This post sticks to what the Next.js 16.4 docs describe. If you are new to the App Router, start with the Next.js developer guide. For the caching side of this story, see Caching in Next.js 16.
How a streamed page is delivered#
On the first load, two things travel in the same response.
The HTML stream comes from React's server renderer, which produces HTML in chunks aligned with <Suspense> boundaries. Layouts, navigation and fallbacks go out first. When a boundary's content is ready, React sends the finished HTML plus a small inline script that swaps it in for the fallback. The swap does not wait for your JavaScript bundle or hydration.
The component payload is the serialized component tree React uses to hydrate the page. On the first load it is embedded in the HTML stream. On client-side navigation, only the payload is fetched (the request carries an rsc: 1 header) and no HTML is sent.
Everything that renders before any async work resolves is the static shell: layouts, navigation and the fallback UI of your boundaries. With Cache Components, that shell is prerendered at build time and can be served from a CDN. The docs call this rendering model Partial Prerendering, and with cacheComponents enabled it is the only model for App Router pages. In the next build route table, a route that serves a static shell and streams the rest is marked ◐ (Partial Prerender).
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
};
export default nextConfig;The docs ask you to set partialPrefetching explicitly alongside cacheComponents. Leaving it unset logs a warning.
Sibling boundaries are independent. If one section resolves in 200ms and another in three seconds, the first one appears as soon as it is ready.
What loading.tsx actually wraps#
A loading.tsx file next to a page.tsx makes Next.js wrap the page in a <Suspense> boundary with your component as the fallback.
error.tsxlayout.tsxloading.tsxpage.tsx
layout.tsx
export default function Loading() {
return (
<div className="animate-pulse space-y-2">
<div className="h-8 w-48 rounded bg-gray-200" />
<div className="h-4 w-full rounded bg-gray-200" />
<div className="h-4 w-2/3 rounded bg-gray-200" />
</div>
);
}The exact scope matters. Inside a segment, loading.tsx is nested inside layout.tsx. It wraps page.tsx, not-found.tsx and any nested layouts below it. It does not wrap the layout.tsx, template.tsx or error.tsx of the same segment.
That has one practical consequence that catches people out.
loading.tsx also has a navigation benefit. Its fallback is prefetched, so clicking a link shows the skeleton immediately, and shared layouts stay interactive while the new segment loads. The file is a Server Component by default, and it takes no props.
Explicit Suspense boundaries#
<Suspense> from React gives you control over which part of the page waits. Instead of one skeleton for the whole page, each slow section gets its own fallback and the rest of the page lands in the shell as real content.
import { Suspense } from "react";
type Order = { id: string; total: number };
async function getOrders(): Promise<Order[]> {
const res = await fetch("https://api.example.com/orders");
return (await res.json()) as Order[];
}
async function getRevenue(): Promise<number> {
const res = await fetch("https://api.example.com/revenue");
const data = (await res.json()) as { total: number };
return data.total;
}
async function Revenue() {
const total = await getRevenue();
return <p>Revenue: {total}</p>;
}
async function RecentOrders() {
const orders = await getOrders();
return (
<ul>
{orders.map((order) => (
<li key={order.id}>{order.total}</li>
))}
</ul>
);
}
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading revenue...</p>}>
<Revenue />
</Suspense>
<Suspense fallback={<p>Loading orders...</p>}>
<RecentOrders />
</Suspense>
</div>
);
}The <h1> is in the static shell. Each data section streams on its own.
Push dynamic access down#
The streaming guide's main advice is to defer request-time access to the component that needs it. That covers params, searchParams, cookies(), headers() and data fetches. If a layout or page awaits one of these at the top, everything below that point becomes dynamic and drops out of the shell.
Start the work without awaiting it and hand the promise to a child inside a boundary:
import { cookies } from "next/headers";
import { Suspense, type ReactNode } from "react";
async function UserMenu({
cookiePromise,
}: {
cookiePromise: ReturnType<typeof cookies>;
}) {
const store = await cookiePromise;
const name = store.get("name")?.value ?? "Guest";
return <span>{name}</span>;
}
export default function DashboardLayout({ children }: { children: ReactNode }) {
const cookiePromise = cookies();
return (
<div>
<nav>
<Suspense fallback={<span>Loading user...</span>}>
<UserMenu cookiePromise={cookiePromise} />
</Suspense>
</nav>
{children}
</div>
);
}The same works for params. You can pass the promise down, or unwrap it inline so the child takes a plain value:
import { Suspense } from "react";
async function ProductGrid({ category }: { category: string }) {
const res = await fetch(`https://api.example.com/products?category=${category}`);
const products = (await res.json()) as { id: string; name: string }[];
return (
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
export default function ShopPage({
params,
}: {
params: Promise<{ category: string }>;
}) {
return (
<div>
<h1>Shop</h1>
<Suspense fallback={<p>Loading products...</p>}>
{params.then(({ category }) => (
<ProductGrid category={category} />
))}
</Suspense>
</div>
);
}You can also nest boundaries. An outer boundary around product details with an inner one around reviews gives a progressive reveal: details first, then reviews.
How granular to go#
More boundaries are not automatically better. The docs make a few points worth keeping in mind:
- LCP. If your largest element (a hero image or main heading) sits inside a boundary, it cannot paint until that boundary is swapped in. Keep LCP elements above or outside boundaries. For images, the
preloadprop onnext/imagestarts the fetch early but does not change when a boundary's content paints. - CLS. When a fallback is replaced by content of a different size, the layout shifts. Make skeletons match the final dimensions, or reserve space with a min-height container.
- INP. Each boundary is a separate hydration unit, so splitting a page lets React hydrate in smaller tasks and prioritize what the user is interacting with.
- Unexpected fallbacks. The guide notes that if there is a boundary, React might use it, even under a slow network or busy CPU when you did not expect it. Do not add one you do not need.
Choosing between them#
The streaming guide compares the two directly:
loading.tsx | <Suspense> | |
|---|---|---|
| Scope | Entire page | Any component |
| Setup | Drop in a file | Wrap components explicitly |
| Navigation | Prefetched as instant fallback | Not prefetched by default |
| Best for | Pages where nothing renders without data | Most pages, for granular control |
Our default follows the docs: put explicit boundaries close to the dynamic access, and use loading.tsx when the page has nothing to show until its data arrives. Be careful using it as a safety net. When the prerenderer hits dynamic work it walks up the tree to the nearest boundary. A loading.tsx high in the tree satisfies that search, so the build passes, but the whole page falls back to one skeleton instead of streaming section by section.
Fixing the blocking route error#
With Cache Components, next build checks that every App Router page can produce a static shell. If request-time work runs outside any boundary, the build fails with an error like this one from the docs:
Error: Route "/products/[id]": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.
Ways to fix this:
- [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
- [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
- [block] Set `export const instant = false` to allow a blocking route
Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamicThe docs split the trigger into two kinds. Request-time values such as params, cookies() and headers() outside <Suspense> produce a runtime data error (blocking-prerender-runtime). Uncached data such as a fetch or database call without "use cache" produces an uncached data error (blocking-prerender-dynamic). In development, the same checks show up in the dev overlay and terminal with stack frames pointing at your source.
The build stack trace often points only at body and html. To find the line, rebuild with next build --debug-prerender, optionally combined with --debug-build-paths to build just the failing route. Do not deploy a build made with --debug-prerender.
Stream it
Wrap the access in
<Suspense>, or add aloading.tsxto the segment. The fallback is prerendered as the shell and the content streams at request time. The route shows as◐in the build output. Prefer a boundary around the specific component so more of the page stays in the shell.Cache it
If the data can be shared across requests, move the fetch into a function or component marked
"use cache". Cached results become part of the static shell, as long as their lifetime is not too short. This does not apply toconnection(), which exists precisely to wait for a request.Prerender known params
For dynamic segments,
generateStaticParamslists the values known at build time, so thoseparamsare no longer request-time data for the listed paths. With Cache Components it must return at least one entry; an empty array is a build error.Allow blocking, deliberately
export const instant = falseon the page or layout tells Next.js this segment is allowed to block. The server then waits for all the work before responding. Use it when the route genuinely needs the full result first, or to defer a refactor while you adopt Cache Components.
The cache and params fixes usually go together. This is the shape the build guide arrives at for a product page:
type Product = { id: string; name: string };
export async function generateStaticParams(): Promise<{ id: string }[]> {
const res = await fetch("https://api.example.com/products");
const products = (await res.json()) as Product[];
return products.map((product) => ({ id: product.id }));
}
async function getProduct(id: string): Promise<Product> {
"use cache";
const res = await fetch(`https://api.example.com/products/${id}`);
return (await res.json()) as Product;
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
return <h1>{product.name}</h1>;
}In the docs' version of this example, the segment keeps its loading.tsx. Listed IDs become full ○ prerenders. The /products/[id] fallback row stays ◐: an unlisted ID gets the loading fallback on its first visit while Next.js renders the page and stores it for later visitors.
error.tsx alongside#
Error boundaries follow the same hierarchy rules, one level further out. error.tsx wraps loading.tsx, not-found.tsx, page.tsx and nested layouts in a React error boundary. Like loading.tsx, it does not cover the layout.tsx or template.tsx of its own segment. Errors in the root layout need app/global-error.tsx, which must render its own <html> and <body>.
"use client";
import { useEffect } from "react";
export default function DashboardError({
error,
retry,
}: {
error: unknown;
retry: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div>
<h2>Something went wrong.</h2>
<button type="button" onClick={() => retry()}>
Try again
</button>
</div>
);
}Two details are specific to recent versions. The retry prop became stable in 16.3; it re-fetches and re-renders the boundary's children. reset still exists, but the docs recommend retry in most cases. And error is typed as unknown, because a value thrown in a Client Component may not be an Error. Narrow it with instanceof Error before reading message or digest.
Streaming changes what an error can do to the response. If a component throws after the stream has started, the nearest error.tsx renders in place of the failed section and the rest of the page stays intact. The status code cannot change, though. The first chunk already committed to 200 OK. A notFound() that fires mid-stream injects <meta name="robots" content="noindex"> instead of returning a 404, and a mid-stream redirect() becomes a client-side redirect.
If you need a real status code, call notFound() before the stream starts, which means before any <Suspense> fallback renders. The existence check below is cached, so it does not trigger the blocking route error itself:
import { notFound } from "next/navigation";
import { Suspense } from "react";
async function postExists(slug: string): Promise<boolean> {
"use cache";
const res = await fetch(`https://api.example.com/posts/${slug}`, { method: "HEAD" });
return res.ok;
}
async function PostBody({ slug }: { slug: string }) {
const res = await fetch(`https://api.example.com/posts/${slug}`);
const post = (await res.json()) as { body: string };
return <article>{post.body}</article>;
}
export async function generateStaticParams(): Promise<{ slug: string }[]> {
return [{ slug: "hello-world" }];
}
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
if (!(await postExists(slug))) notFound();
return (
<Suspense fallback={<p>Loading post...</p>}>
<PostBody slug={slug} />
</Suspense>
);
}Proxy and next.config redirects run before rendering too, so they can still set status codes.
What bots and crawlers receive#
Metadata is where streaming meets SEO. With streaming metadata, Next.js sends the initial UI without waiting for generateMetadata, then appends the resulting tags to <body> when it resolves. The docs say this has been verified with bots that execute JavaScript and inspect the full DOM, with Googlebot as the example.
HTML-limited bots that cannot run JavaScript are treated differently. Next.js detects them by user agent and resolves generateMetadata before streaming, so the tags land in <head>. The default list includes Google crawlers such as Mediapartners-Google, AdsBot-Google and Google-PageRenderer, plus Bingbot, Twitterbot and Slackbot. The htmlLimitedBots option replaces that list with your own pattern:
import type { NextConfig } from "next";
const config: NextConfig = {
htmlLimitedBots: /MySpecialBot|MyAnotherSpecialBot|SimpleCrawler/,
};
export default config;Setting this overrides the default list rather than adding to it. The docs call it advanced behavior and say the default should be enough for most apps.
import type { NextConfig } from "next";
const config: NextConfig = {
htmlLimitedBots: /.*/,
};
export default config;Matching every user agent makes all requests receive blocking metadata.
So do bots get fully rendered HTML? The streaming guide's own test answers it. A request with a Twitterbot/1.0 user agent blocks until the full render completes and then receives the whole document in one burst, with no staggered chunks. The server waits for the finished document instead of streaming. Regular browsers and DOM-capable crawlers get the streamed version, and since streaming is server-rendered, the docs state it does not hurt SEO. Google's Rich Results Test shows the serialized HTML Google sees.
With Cache Components there is one more wrinkle. Visitors and DOM-capable crawlers get the prerendered shell straight away. HTML-limited bots skip the prerendered shell and render the page dynamically at request time, so metadata can go in <head>. That means any code the shell depends on runs again for those bots. If the shell relies on something only available at build time, the page can load fine for people and throw for a crawler, which then receives a 500. Make sure every data source your shell uses is reachable at request time too.
When streaming does not seem to work#
If boundaries resolve but the page still arrives at once, something between the server and the browser is buffering. The docs list the usual suspects: Nginx and similar proxies (send X-Accel-Buffering: no), CDNs that buffer full responses, serverless platforms where streaming must be enabled (AWS Lambda's response streaming mode is not the default), and compression layers that hold chunks. Static export does not support streaming at all.
Summary#
loading.tsxwraps the page and nested layouts of its segment, not the segment's own layout, template or error file.- Put
<Suspense>close to the component that reads request data, and keep LCP content outside it. - Grow the static shell with
"use cache"andgenerateStaticParams; useinstant = falseonly when blocking is the right behavior. - Do status-code checks before the first boundary, because a streamed response is already
200. - HTML-limited bots get blocking metadata and a dynamic render, so the shell's data must exist at request time.
If you are moving an existing app onto this model, the Next.js 16 upgrade guide covers turning on cacheComponents and the build errors that tend to follow.
