Search across all documentation pages
Stream server-rendered content progressively using React Suspense boundaries and loading.tsx.
Quick-reference recipe card -- copy-paste ready.
// app/dashboard/loading.tsx -- instant loading UI for the entire route
export default function Loading() {
return <div className="animate-pulse">Loading dashboard...</div>;
}// app/dashboard/page.tsx -- granular streaming with Suspense
import { Suspense } from "react";
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading stats...</p>}>
<SlowStats />
</Suspense>
<Suspense fallback
When to reach for this: You have a page with slow data sources and want to show content progressively instead of blocking the entire page on the slowest query.
// app/dashboard/page.tsx
import { Suspense } from "react";
import { RecentOrders } from "./recent-orders";
import { RevenueChart } from "./revenue-chart";
import { TopProducts } from "./top-products";
export default function DashboardPage() {
return (
// app/dashboard/revenue-chart.tsx (Server Component)
import { db } from "@/lib/db";
import { ChartClient } from "./chart-client";
export async function RevenueChart() {
// Simulate a slow query
const revenue = await db.order.aggregate({
_sum: { total: true },
// app/dashboard/loading.tsx
// This file creates an automatic Suspense boundary around the page
export default function DashboardLoading() {
return (
<div className="grid grid-cols-2 gap-6 p-6">
<h1 className="col-span-2 text-2xl font-bold">Dashboard</h1>
<div className="h-64 bg-gray-100 rounded animate-pulse"
What this demonstrates:
loading.tsx as a route-level Suspense boundary for instant navigation feedback<Suspense> boundaries so each dashboard widget streams independentlyloading.tsx: Next.js automatically wraps the page component in a <Suspense> boundary using loading.tsx as the fallback. This provides an instant loading state during navigation.<Suspense> boundaries at any granularity. Each boundary independently resolves and replaces its fallback with the real content.<Link>), React renders the loading.tsx fallback immediately while fetching the RSC payload for the new route.Transfer-Encoding: chunked to send HTML progressively. This requires a runtime that supports streaming (Node.js, Edge).Streaming with a passed promise (defer pattern):
// Server Component passes a promise without awaiting
export default async function Page() {
const analyticsPromise = fetchAnalytics(); // not awaited
return (
<Suspense fallback={<p>Loading analytics...</p>}>
<Analytics dataPromise={analyticsPromise} />
Sequential vs parallel streaming:
// Sequential -- each awaits in order (waterfall)
async function Sequential() {
const a = await fetchA(); // blocks
const b = await fetchB(); // waits for a
return <>{a}{b}</>;
}
// Parallel -- separate Suspense boundaries
function Parallel() {
return
// loading.tsx must be a default export returning ReactNode
export default function Loading(): React.ReactNode {
return <Skeleton />;
}
// Suspense fallback accepts ReactNode
<Suspense fallback={<div>Loading...</div>}>
<AsyncComponent
Suspense boundary too high -- Wrapping your entire page in a single Suspense boundary means nothing shows until all data resolves. Fix: Use multiple granular Suspense boundaries around each independent data source.
Suspense boundary too low -- Wrapping every tiny component in Suspense creates excessive loading spinners and visual noise. Fix: Group related components under a single boundary for a cohesive loading experience.
Layout shift from skeletons -- If your skeleton does not match the dimensions of the real content, the page jumps when data arrives. Fix: Make skeletons the same height/width as the resolved content using fixed dimensions or aspect ratios.
loading.tsx applies to navigations only -- On a hard refresh (full page load), loading.tsx is rendered as part of the initial HTML, but it does not create a true streaming boundary for the initial SSR in all cases. Fix: Use explicit <Suspense> boundaries inside your page for reliable streaming behavior.
Static routes do not stream -- If a route is fully static (no dynamic data), it is prerendered at build time and served as a complete HTML file. Streaming only applies to dynamic routes. Fix: This is expected behavior; no fix needed.
Error boundaries and Suspense -- If a Suspense child throws, the error bubbles up. Without an error.tsx or <ErrorBoundary>, the entire page fails. Fix: Pair Suspense boundaries with error boundaries at the same level.
| Alternative | Use When | Don't Use When |
|---|---|---|
loading.tsx | You want a simple route-level loading state | You need granular control over which parts stream |
Nested <Suspense> | Each section has independent data sources | All data comes from a single fast query |
| Client-side fetching (SWR) | You need real-time updates after the initial load | Server-side streaming is sufficient |
| Static generation | Data does not change between deployments | Data is user-specific or frequently updated |
| Partial Prerendering (PPR) | You want a static shell with dynamic holes | Your entire page is dynamic |
From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).
// Production example: SSE streaming with markdown buffering
// File: src/hooks/use-stream-content.ts
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let buffer = '';
const hasIncompleteMarkdown = (text: string): boolean
What this demonstrates in production:
response.body.getReader() returns a ReadableStreamDefaultReader for processing data as it arrivesTextDecoder({ stream: true }) handles multi-byte UTF-8 characters that may be split across chunkshasIncompleteMarkdown() prevents rendering partial markdown (unmatched bold markers, unclosed code blocks, incomplete links) which would flash broken formattingoptions.signal?.aborted checks the AbortController signal allowing the user to cancel mid-streamaccumulated builds the full content for final save, while setState triggers incremental UI rendersJSON.parse on each SSE line could fail on malformed data. The silent catch is correct behavior for streamingloading.tsx and an explicit <Suspense> boundary?loading.tsx creates an automatic route-level Suspense boundary around the entire page<Suspense> boundaries give granular control over which parts stream independentlyloading.tsx for a quick loading state; use <Suspense> for fine-grained streamingerror.tsx or <ErrorBoundary>, the entire page fails// Server Component: do NOT await the promise
export default async function Page() {
const dataPromise = fetchAnalytics();
return (
<Suspense fallback={<p>Loading...</p>}>
<AnalyticsClient dataPromise={dataPromise} />
loading.tsx?export default function Loading(): React.ReactNode {
return <Skeleton />;
}React.ReactNodeTransfer-Encoding: chunked to send HTML progressivelyuse() hook infer types from a promise?const data: Data = use(dataPromise);
// TypeScript infers Data from Promise<Data> automaticallyuse() unwraps the promise type, so use(Promise<T>) returns Tloading.tsx not always create a true streaming boundary on full page loads?loading.tsx is rendered as part of the initial HTML<Suspense> boundaries inside your page for reliable streaming on initial loadhasIncompleteMarkdown function in the real-world example protect against?**) or unclosed code blocks