
How to Stream UI with loading.tsx and React Suspense in Next.js?
Every server-rendered page has a slowest query. It might be a recommendations API that takes two seconds, an analytics aggregate, or a third-party inventory check. In classic server-side rendering, that one slow call holds the entire response hostage: the header, the navigation, and the content that was ready in 20 milliseconds all wait until the slowest piece finishes. Users stare at a blank tab, and your Time to First Byte looks terrible.
The Next.js App Router fixes this with streaming. The server sends the parts of the page that are ready immediately, shows placeholders for the parts that are not, and fills those placeholders in over the same HTTP response as the data arrives. You control where those placeholders go with two tools: the loading.tsx file convention and React's Suspense component.
This article covers streaming in the App Router from start to finish: how the static shell and HTML stream work, page-level loading states with loading.tsx, granular boundaries with Suspense, pushing dynamic data access down the tree, passing promises to Client Components with use, error handling and status codes mid-stream, how Cache Components changes the rules, and the infrastructure settings that can quietly break streaming in production.
What Streaming Actually Does
Streaming uses chunked transfer encoding to send an HTML response in pieces. React's server renderer produces those pieces aligned with Suspense boundaries:
- The static shell goes first. Layouts, navigation, and every
Suspensefallback render immediately and are flushed to the browser. The user sees a real page structure right away. - Each boundary resolves independently. When an async Server Component inside a boundary finishes, React streams its HTML along with a small inline script that swaps the fallback for the real content.
- Hydration happens selectively. React hydrates boundaries as their code and data arrive, prioritizing whatever the user is interacting with.
| Traditional SSR | Streaming in the App Router |
|---|---|
| Server waits for all data before sending bytes | Server sends the shell immediately |
| Slowest query sets Time to First Byte | TTFB is independent of slow queries |
| Blank screen until the full page arrives | Layout and skeletons paint first |
| One hydration pass for the whole page | Boundaries hydrate independently |
| A failure anywhere fails the whole render | Errors are contained to the nearest boundary |
On client-side navigation the same model applies, except no HTML is transferred: the router fetches the React Server Component payload and streams it into the existing page. If you want a refresher on how that payload is produced, see what server-side rendering is in Next.js.
Streaming needs no configuration. If you use async Server Components and the App Router, you are already set up for it. What you decide is where the boundaries go.
A Slow Page to Work With
To make the examples concrete, here is a dashboard with three data sources of very different speeds:
// app/dashboard/data.ts
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function getStats() {
await wait(300);
return { revenue: 48210, orders: 1294, customers: 812 };
}
export async function getRecentOrders() {
await wait(1200);
return [
{ id: "A-1042", customer: "Rahim", total: 129 },
{ id: "A-1041", customer: "Nadia", total: 58 },
{ id: "A-1040", customer: "Tanvir", total: 240 },
];
}
export async function getRecommendations() {
await wait(2500);
return [
"Restock USB-C cables",
"Email lapsed customers",
"Raise free-shipping threshold",
];
}
Written naively, the page awaits everything at the top:
// app/dashboard/page.tsx
import { getStats, getRecentOrders, getRecommendations } from "./data";
export default async function DashboardPage() {
const stats = await getStats();
const orders = await getRecentOrders();
const recommendations = await getRecommendations();
return (
<main>
<h1>Dashboard</h1>
{/* render stats, orders, recommendations */}
</main>
);
}
This page takes about four seconds before anything appears, because the awaits run one after another and nothing is sent until the last one finishes. Even switching to Promise.all only reduces that to 2.5 seconds. The fix is not faster queries, it is not making the user wait for all of them.
Page-Level Streaming with loading.tsx
The quickest improvement is a loading.tsx file next to the page:
// app/dashboard/loading.tsx
export default function Loading() {
return (
<main aria-busy="true" aria-live="polite">
<div className="mb-6 h-9 w-48 animate-pulse rounded bg-gray-200" />
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-28 animate-pulse rounded-lg bg-gray-200" />
))}
</div>
<div className="mt-6 h-64 animate-pulse rounded-lg bg-gray-200" />
</main>
);
}
Next.js wraps the page in a Suspense boundary and uses this component as the fallback. The component hierarchy for a segment looks like this:
// What Next.js builds for app/dashboard (simplified)
<Layout>
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<Page />
</Suspense>
</ErrorBoundary>
</Layout>
What you get:
- Instant feedback on navigation. The loading UI is prefetched with the route, so clicking a link to
/dashboardshows the skeleton immediately instead of freezing on the previous page. - Shared layouts stay interactive. The sidebar and header in parent layouts remain usable while the page loads, and navigation is interruptible.
- The layout itself is not wrapped.
loading.tsxwrapspage.tsx, nested layouts, andnot-found.tsxin the same segment, but not thelayout.tsxbeside it. If that layout awaits slow data, the loading state cannot help it.
The downside is granularity. The whole page is one boundary, so the user sees a full-page skeleton for 2.5 seconds even though the stats were ready after 300 milliseconds.
Granular Streaming with Suspense
To let each section appear as soon as its data is ready, move the data fetching into separate async components and wrap each one in its own Suspense boundary:
// app/dashboard/page.tsx
import { Suspense } from "react";
import { getStats, getRecentOrders, getRecommendations } from "./data";
async function Stats() {
const stats = await getStats();
return (
<section className="grid gap-4 md:grid-cols-3">
<Card label="Revenue" value={`$${stats.revenue.toLocaleString()}`} />
<Card label="Orders" value={stats.orders} />
<Card label="Customers" value={stats.customers} />
</section>
);
}
async function RecentOrders() {
const orders = await getRecentOrders();
return (
<ul className="divide-y rounded-lg border">
{orders.map((order) => (
<li key={order.id} className="flex justify-between p-4">
<span>
{order.id} · {order.customer}
</span>
<span>${order.total}</span>
</li>
))}
</ul>
);
}
async function Recommendations() {
const items = await getRecommendations();
return (
<ol className="list-decimal space-y-2 pl-6">
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ol>
);
}
function Card({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border p-4">
<p className="text-sm text-gray-500">{label}</p>
<p className="text-2xl font-semibold">{value}</p>
</div>
);
}
function Skeleton({ className }: { className: string }) {
return (
<div className={`animate-pulse rounded-lg bg-gray-200 ${className}`} />
);
}
export default function DashboardPage() {
return (
<main className="space-y-6">
<h1 className="text-3xl font-bold">Dashboard</h1>
<Suspense fallback={<Skeleton className="h-28" />}>
<Stats />
</Suspense>
<div className="grid gap-6 md:grid-cols-2">
<Suspense fallback={<Skeleton className="h-56" />}>
<RecentOrders />
</Suspense>
<Suspense fallback={<Skeleton className="h-56" />}>
<Recommendations />
</Suspense>
</div>
</main>
);
}
Now the page component itself is not async. It renders the heading and three fallbacks instantly, and each section streams in on its own schedule: stats at roughly 300 milliseconds, orders at 1.2 seconds, recommendations at 2.5 seconds. Because the components start their work in parallel during rendering, the total time is set by the slowest one, and nothing waits on anything else.
Sibling Versus Nested Boundaries
How you arrange boundaries changes the loading sequence:
- Sibling boundaries resolve independently and in any order. Use them for unrelated sections, like the cards in a dashboard.
- Nested boundaries reveal content progressively from the outside in. An outer boundary shows the product details, and an inner boundary inside it shows reviews once they arrive. The inner fallback is only visible after the outer content has resolved.
- One boundary around several components reveals them together. Use this when partial content would look broken, such as a chart and its legend.
A useful rule is to design boundaries around what the user perceives as one unit, not around your data sources.
loading.tsx Versus Suspense
| loading.tsx | Suspense | |
|---|---|---|
| Scope | The whole page segment | Any component |
| Setup | Add one file | Wrap components explicitly |
| Navigation | Prefetched, shown instantly on click | Shown once the route starts rendering |
| Best for | Pages that render nothing without data | Most pages, for granular control |
They are not mutually exclusive. A common pattern is a lightweight loading.tsx for instant navigation feedback plus Suspense boundaries inside the page for section-by-section streaming.
Push Dynamic Access Down the Tree
The most important streaming habit is to await data in the component that needs it, not at the top. This applies to data fetches and also to params, searchParams, cookies(), and headers(), which are all promises in Next.js 15 and later. If a layout calls await cookies() at the top, everything below it is blocked.
Instead, start the work without awaiting and pass the promise down:
// app/dashboard/layout.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { UserMenu } from "./user-menu";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const cookieStore = cookies(); // start the work, do not await
return (
<div className="min-h-screen">
<header className="flex items-center justify-between border-b p-4">
<span className="font-semibold">Acme Admin</span>
<Suspense
fallback={<div className="h-8 w-8 rounded-full bg-gray-200" />}
>
<UserMenu cookiePromise={cookieStore} />
</Suspense>
</header>
{children}
</div>
);
}
// app/dashboard/user-menu.tsx
import { getUserFromSession } from "@/lib/auth";
type CookieStore = Awaited<ReturnType<typeof import("next/headers").cookies>>;
export async function UserMenu({
cookiePromise,
}: {
cookiePromise: Promise<CookieStore>;
}) {
const store = await cookiePromise;
const user = await getUserFromSession(store.get("session")?.value);
return <span className="text-sm">{user?.name ?? "Guest"}</span>;
}
The header renders as part of the shell, and only the avatar area waits for the session. The same idea applies to route params: pass the params promise into the component inside the boundary, or unwrap it inline with params.then(...) inside Suspense.
Streaming Data to Client Components
Client Components cannot be async, but they can still participate in streaming. Start a fetch in a Server Component, pass the unresolved promise as a prop, and read it in the Client Component with React's use API:
// app/dashboard/analytics/page.tsx
import { Suspense } from "react";
import { RevenueChart } from "./revenue-chart";
type Point = { day: string; revenue: number };
async function getRevenueSeries(): Promise<Point[]> {
const res = await fetch("https://api.example.com/revenue?range=30d");
if (!res.ok) throw new Error("Failed to load revenue");
return res.json();
}
export default function AnalyticsPage() {
const seriesPromise = getRevenueSeries(); // do not await
return (
<Suspense fallback={<p>Loading chart...</p>}>
<RevenueChart seriesPromise={seriesPromise} />
</Suspense>
);
}
// app/dashboard/analytics/revenue-chart.tsx
"use client";
import { use, useState } from "react";
type Point = { day: string; revenue: number };
export function RevenueChart({
seriesPromise,
}: {
seriesPromise: Promise<Point[]>;
}) {
const series = use(seriesPromise);
const [highlight, setHighlight] = useState<string | null>(null);
const max = Math.max(...series.map((p) => p.revenue), 1);
return (
<div className="flex h-48 items-end gap-1">
{series.map((point) => (
<button
key={point.day}
type="button"
onMouseEnter={() => setHighlight(point.day)}
className={highlight === point.day ? "bg-blue-700" : "bg-blue-400"}
style={{ height: `${(point.revenue / max) * 100}%`, flex: 1 }}
aria-label={`${point.day}: $${point.revenue}`}
/>
))}
</div>
);
}
The fetch starts on the server during the first render, the fallback ships in the shell, and the resolved data streams to the browser when ready. The chart stays interactive because it is a Client Component. Only the component that calls use needs a Suspense boundary above it.
Errors, Status Codes, and notFound
Streaming changes how errors work because the HTTP status line is sent with the first chunk.
- Errors inside a boundary are contained. If
Recommendationsthrows after the shell has been sent, the nearesterror.tsxboundary catches it and replaces that part of the tree. Placeerror.tsxfiles at the segment level, and see implementing error handling in a Next.js application for the full pattern. - The status code is already 200. Once streaming starts, the response cannot change to 404 or 500. Next.js adds a
noindexrobots meta tag when a streamed page turns out to be a not-found page, so search engines do not index it. - Call notFound and redirect early. If you need a real 404 status, check that the resource exists before the first
Suspenseboundary orloading.tsxfallback renders. Put the existence check at the top of the page, before any boundary, and keep it fast.
// app/blog/[slug]/page.tsx
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { postExists, getPost, getComments } from "@/lib/posts";
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
if (!(await postExists(slug))) notFound(); // runs before streaming starts
return (
<article>
<PostBody slug={slug} />
<Suspense fallback={<p>Loading comments...</p>}>
<Comments slug={slug} />
</Suspense>
</article>
);
}
async function PostBody({ slug }: { slug: string }) {
const post = await getPost(slug);
return <div>{post.title}</div>;
}
async function Comments({ slug }: { slug: string }) {
const comments = await getComments(slug);
return <p>{comments.length} comments</p>;
}
Note that a loading.tsx in the same segment would start streaming before this check, so pages that need precise status codes should rely on inner Suspense boundaries rather than a segment-level loading file.
Streaming and SEO
Streamed content is server-rendered HTML, so search engines see it. Next.js also detects bots that only read static HTML, such as some social media crawlers, and sends them a fully rendered document with metadata in the head instead of a stream. You do not have to choose between streaming and crawlability. For broader guidance, read the best practices for SEO in Next.js websites.
Streaming with Cache Components
Next.js 16 introduced Cache Components, enabled with cacheComponents: true in next.config.ts. It makes streaming boundaries a first-class part of how routes are built:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;
With it enabled, everything outside a Suspense boundary is prerendered into a static shell at build time, and anything uncached or request-specific must sit inside a boundary. If a component accesses uncached data or runtime APIs with no boundary above it, the build fails with a blocking-route error that points you to the component. You can either wrap it in Suspense so it streams, or mark the data with the "use cache" directive so it becomes part of the shell.
This makes boundary placement a deliberate decision rather than an optimization you might forget. A high-level loading.tsx technically satisfies the requirement, but it turns the whole page into a skeleton; boundaries close to the dynamic access give a much richer shell.
Infrastructure That Breaks Streaming
Streaming can work perfectly in next dev and silently stop in production. Anything that buffers the response defeats it:
- Nginx and other reverse proxies buffer responses by default. Send an
X-Accel-Buffering: noheader from Next.js or disableproxy_bufferingfor the app. - Compression layers can hold chunks until enough data accumulates. If the first visible chunk is delayed, check how your gzip or Brotli layer flushes.
- CDNs and serverless platforms vary. Some require configuration or a specific plan to pass chunked responses through. Vercel supports streaming natively, and Node.js and Docker deployments support it out of the box.
- Static exports do not support streaming at all, since there is no server.
The Nginx header can be set in Next.js config:
// next.config.mjs
const nextConfig = {
async headers() {
return [
{
source: "/:path*{/}?",
headers: [{ key: "X-Accel-Buffering", value: "no" }],
},
];
},
};
export default nextConfig;
To confirm streaming works end to end, open the document request in Chrome DevTools and check the Timing tab. A short Time to First Byte followed by a long Content Download phase means the response is arriving in chunks.
Designing Good Fallbacks
The fallback is part of your UI, so treat it like one:
- Match the final dimensions. A skeleton that is the same height as the resolved content avoids layout shift when it swaps. Mismatched heights cause Cumulative Layout Shift, which hurts Core Web Vitals.
- Keep fallbacks lightweight. They ship in the shell, so avoid heavy components or images.
- Announce loading to assistive technology.
aria-busyon the loading region helps screen readers understand that content is coming. - Re-show fallbacks on parameter changes with a key. When a search page re-renders with new
searchParams, React keeps the old content visible during the transition. Addingkey={query}to theSuspenseboundary forces it to show the fallback again for the new query.
For more ways to improve perceived speed beyond streaming, see how to optimize performance in a Next.js app.
Common Problems and Fixes
- The loading state never shows. The page or layout has no async work inside the boundary, or the slow work happens in a layout above
loading.tsx. Move the await into the page or into a component insideSuspense. - Everything still arrives at once in production. A proxy, CDN, or compression layer is buffering. Disable buffering and verify with the DevTools timing view.
- Sections load one after another instead of in parallel. You awaited data sequentially at the top of the page. Move each fetch into its own component inside its own boundary.
- Build error about a blocking route with Cache Components. An uncached fetch or runtime API sits outside any
Suspenseboundary. Wrap it, or cache it with"use cache". - A 404 page returns status 200. Streaming had already started. Check existence before any boundary renders, or handle it in
proxy.ts. - Layout shift when content swaps in. The fallback and the real content have different heights. Size the skeleton to match.
Streaming UI FAQ
No. Streaming is built into the App Router. Any async Server Component inside a Suspense boundary, or inside a segment with a loading.tsx file, streams automatically. You only need to make sure your hosting infrastructure does not buffer responses.
Use Suspense boundaries close to the slow data for most pages, because they let the rest of the page render immediately. Use loading.tsx when a page has nothing meaningful to show until its data resolves, or as instant navigation feedback alongside inner Suspense boundaries.
No. Streamed content is server-rendered HTML that search engines can read. Next.js also sends a fully rendered document to bots that cannot process streams, with metadata placed in the head.
Yes. Start the fetch in a Server Component, pass the unresolved promise to the Client Component as a prop, and read it with the use API from React. Wrap the Client Component in a Suspense boundary.
loading.tsx wraps the page and nested segments, but not the layout in the same folder. If that layout awaits slow data, move the fetch into the page or wrap the slow part of the layout in its own Suspense boundary.
No. Streaming requires a running server that sends the response in chunks. Sites built with output export are served as plain static files.
Conclusion
Streaming turns your slowest data source from a page-wide problem into a local one. The shell paints immediately, each section appears as soon as its data is ready, and errors stay contained to the part of the page that failed.
Start with a loading.tsx for instant navigation feedback. Then move data fetching into the components that need it, wrap each independent section in its own Suspense boundary, and pass promises down rather than awaiting at the top. Check for missing resources before streaming begins when status codes matter, size your skeletons to avoid layout shift, and make sure nothing between your server and your users is buffering the response.
Here are some useful references for going deeper on streaming in Next.js:
- Next.js Docs: Streaming guide — how the HTML stream, static shell, and boundaries work together.
- Next.js Docs: loading.js — the file convention, its behavior, and status code details.
- React Docs: Suspense — boundary behavior, nesting, and transitions.
- React Docs: use — reading promises in Client Components.
- Next.js Docs: cacheComponents — how Cache Components builds a static shell around your boundaries.


