Introduction: Why Traditional SSR Falls Short
Here's the thing about traditional server-side rendering: it has a fundamental flaw that no amount of optimization can fully fix. It's inherently sequential. The server fetches all data, renders the complete HTML, and only then sends the response to the browser. If you've got a page pulling data from three different APIs — one fast, one moderate, one slow — your user sees absolutely nothing until that slowest API responds. The entire page is held hostage by the weakest link in the data chain.
Next.js streaming with React Suspense changes this equation entirely. Instead of waiting for everything, the server sends an initial HTML shell immediately and then progressively streams additional chunks of UI as their data becomes available. Users see content appearing in stages — the fast parts arrive first, slower sections fill in afterward, and the page feels alive from the very first millisecond.
This guide covers everything you need to build production-grade streaming experiences in Next.js. We'll explore the loading.js convention, manual Suspense boundaries, skeleton loading states, error handling during streaming, the relationship between streaming and Partial Prerendering, and the anti-patterns that can silently destroy your performance gains. If you've read our earlier guides on cache components and parallel routes, this article fills in the missing piece of the performance puzzle.
How Streaming Works Under the Hood
Before diving into the API, it helps to understand what actually happens on the wire when Next.js streams a response. Trust me — this mental model will make every decision later in the guide feel intuitive rather than arbitrary.
The React Flight Protocol
React Server Components don't send raw HTML to the client. They send a serialized representation of the React component tree using the React Flight protocol over a chunked HTTP response. Each chunk corresponds to a resolved server component. The client-side React runtime receives these chunks incrementally and reconstructs the component tree as pieces arrive — reconciling them into the DOM without waiting for everything.
When a server component is wrapped in a <Suspense> boundary and its data isn't ready yet, React pauses that subtree. The server continues rendering the rest of the component tree and sends whatever is already resolved. When the suspended component's data arrives, its resolved output is sent as a subsequent chunk, and the client replaces the fallback with the real content.
What the Browser Receives
From the browser's perspective, it receives an HTTP response that keeps arriving over time. The initial payload contains the page shell — layouts, navigation, static content, and Suspense fallbacks. As the connection stays open, additional script tags arrive that inject the streamed content into the correct positions in the DOM. And this all happens before JavaScript hydration even begins for those sections.
// Simplified view of what the browser receives over time:
// Chunk 1 (immediate): Static shell + fallback UI
<html>
<body>
<nav>...</nav>
<main>
<div id="dashboard-metrics">
<!-- Skeleton placeholder -->
<div class="skeleton-card"></div>
</div>
</main>
</body>
</html>
// Chunk 2 (200ms later): Resolved metrics component
<script>
// React replaces skeleton with actual content
$RC("dashboard-metrics", "<div class='metric-card'>Revenue: $42,000</div>...")
</script>
Streaming vs. Traditional SSR: The Numbers
The performance difference isn't incremental — it's transformational. Real-world benchmarks consistently show that switching from traditional SSR to a streaming architecture reduces Time to First Byte (TTFB) from 350–550ms down to 40–90ms when combined with Partial Prerendering. Largest Contentful Paint (LCP) drops from 1.2 seconds to under 400ms. These aren't synthetic lab numbers; they come from production applications serving real traffic.
The reason is simple: with traditional SSR, TTFB reflects the total time of your slowest data fetch plus rendering. With streaming, TTFB reflects only the time to serve the static shell — which, with PPR, comes directly from the edge cache at CDN speed.
The loading.js Convention: Route-Level Streaming
Alright, the simplest way to add streaming to your Next.js app is through the loading.js (or loading.tsx) file convention. Just place this special file in any route segment directory, and Next.js automatically wraps that segment's page.js in a <Suspense> boundary using your loading component as the fallback. That's it.
Basic Setup
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="dashboard-skeleton">
<div className="skeleton-header animate-pulse h-8 w-64 bg-gray-200 rounded" />
<div className="skeleton-grid grid grid-cols-3 gap-4 mt-6">
{[1, 2, 3].map((i) => (
<div key={i} className="skeleton-card animate-pulse h-32 bg-gray-200 rounded-lg" />
))}
</div>
<div className="skeleton-chart animate-pulse h-64 mt-6 bg-gray-200 rounded-lg" />
</div>
);
}
How Next.js Wires It Up
Behind the scenes, Next.js nests loading.js inside layout.js and wraps page.js and all its children in a <Suspense> boundary. The result is equivalent to writing this manually:
// What Next.js generates internally
<Layout>
<Suspense fallback={<Loading />}>
<Page />
</Suspense>
</Layout>
This means your layout — including navigation, sidebar, and any other shared UI — renders immediately. The loading state appears within the layout's content area while the page's data fetches complete. And here's a nice bonus: navigation is interruptible, meaning users can click another link without waiting for the current page to finish loading.
Key Behaviors to Understand
- Server Component by default: The
loading.tsxfile is a Server Component, which means it ships zero JavaScript to the client. You can also make it a Client Component with the"use client"directive if you need interactivity in your loading state, such as animated progress indicators. - Prefetching support: The fallback UI is included in the prefetched response, making navigations feel instant. When a user hovers over a link, Next.js prefetches the route — including the loading state — so the skeleton appears without any network delay.
- Nested streaming: Each route segment can have its own
loading.tsx. A parent layout streams its loading state while a child segment also streams independently, creating a cascade of progressive rendering.
Manual Suspense Boundaries: Component-Level Streaming
The loading.js convention works great for entire route segments, but real-world pages rarely have a single data dependency. A dashboard might fetch metrics from one API, a chart from another, and a feed from a third. You want each section to stream independently, and that's where manual Suspense boundaries come in.
The Fundamental Pattern
// app/dashboard/page.tsx
import { Suspense } from "react";
import { MetricsPanel } from "./metrics-panel";
import { SalesChart } from "./sales-chart";
import { RecentOrders } from "./recent-orders";
import { MetricsSkeleton, ChartSkeleton, OrdersSkeleton } from "./skeletons";
export default function DashboardPage() {
return (
<div className="dashboard">
<h1>Dashboard</h1>
<div className="grid grid-cols-3 gap-4">
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel />
</Suspense>
</div>
<div className="grid grid-cols-2 gap-6 mt-8">
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
</div>
</div>
);
}
Each async component fetches its own data and is wrapped in its own Suspense boundary. Because they're siblings in the component tree, React executes them in parallel on the server. The heading and layout grid render immediately. As each data fetch completes, the corresponding skeleton gets replaced with real content — independently from the others.
Async Server Components as Data Fetchers
The components inside the Suspense boundaries are async Server Components that fetch their own data. This pattern — sometimes called "fetch where you render" — eliminates the need for centralized data fetching at the page level and naturally enables parallel execution:
// app/dashboard/metrics-panel.tsx
async function getMetrics() {
const res = await fetch("https://api.example.com/metrics", {
next: { revalidate: 60 },
});
if (!res.ok) throw new Error("Failed to fetch metrics");
return res.json();
}
export async function MetricsPanel() {
const metrics = await getMetrics();
return (
<>
{metrics.map((metric) => (
<div key={metric.id} className="metric-card p-4 bg-white rounded-lg shadow">
<p className="text-sm text-gray-500">{metric.label}</p>
<p className="text-2xl font-bold">{metric.value}</p>
<span className={metric.trend > 0 ? "text-green-500" : "text-red-500"}>
{metric.trend > 0 ? "+" : ""}{metric.trend}%
</span>
</div>
))}
</>
);
}
Designing Effective Skeleton Loading States
Skeletons aren't just placeholder rectangles. Done well, they communicate structure, set expectations, and make the eventual content swap feel seamless. Done poorly, they create jarring layout shifts that actually make the experience worse than a simple spinner. I've seen teams ship skeletons that look nothing like the final UI, and honestly, it confuses users more than it helps.
Anatomy of a Good Skeleton
An effective skeleton mimics the shape and proportions of the content it represents. If a metric card has a small label on top and a large number below, your skeleton should have a narrow rectangle on top and a wider one below. The spatial relationship between elements needs to be preserved.
// app/dashboard/skeletons.tsx
export function MetricsSkeleton() {
return (
<>
{[1, 2, 3].map((i) => (
<div key={i} className="p-4 bg-white rounded-lg shadow">
<div className="animate-pulse">
<div className="h-4 w-20 bg-gray-200 rounded" />
<div className="h-8 w-32 bg-gray-200 rounded mt-2" />
<div className="h-4 w-16 bg-gray-200 rounded mt-1" />
</div>
</div>
))}
</>
);
}
export function ChartSkeleton() {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="animate-pulse">
<div className="h-6 w-40 bg-gray-200 rounded mb-4" />
<div className="h-64 bg-gray-200 rounded" />
</div>
</div>
);
}
export function OrdersSkeleton() {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="animate-pulse">
<div className="h-6 w-40 bg-gray-200 rounded mb-4" />
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-4 py-3 border-b border-gray-100">
<div className="h-10 w-10 bg-gray-200 rounded-full" />
<div className="flex-1">
<div className="h-4 w-3/4 bg-gray-200 rounded" />
<div className="h-3 w-1/2 bg-gray-200 rounded mt-1" />
</div>
<div className="h-4 w-16 bg-gray-200 rounded" />
</div>
))}
</div>
</div>
);
}
Preventing Cumulative Layout Shift (CLS)
The most critical rule: your skeleton must occupy the same dimensions as the final content. If a chart area is 400 pixels tall in its loaded state, the skeleton must also be 400 pixels tall. Any mismatch creates a layout shift when the real content streams in, and that hurts both the user experience and your Core Web Vitals score.
Use fixed heights, aspect ratios, or CSS Grid with explicit row definitions to ensure dimensional stability:
/* Ensure the chart container maintains consistent height */
.chart-container {
min-height: 400px;
aspect-ratio: 16 / 9;
}
/* Grid with explicit rows prevents shifts */
.dashboard-grid {
display: grid;
grid-template-rows: 120px 1fr;
gap: 1.5rem;
}
The Goldilocks Zone: How Many Suspense Boundaries?
One of the hardest design decisions with streaming is figuring out the right number of Suspense boundaries. Too few, and you recreate the problems of traditional SSR. Too many, and you create entirely new problems that are equally frustrating.
The Waterfall Problem: Too Few Boundaries
If you wrap your entire page in a single Suspense boundary, nothing appears until every data fetch completes. You've technically enabled streaming, but the user experience is identical to traditional SSR. You're paying the complexity cost of Suspense without getting any benefit.
// Anti-pattern: Single boundary defeats the purpose of streaming
export default function DashboardPage() {
return (
<Suspense fallback={<FullPageSkeleton />}>
<Metrics /> {/* Fast: 50ms */}
<SalesChart /> {/* Moderate: 300ms */}
<ActivityFeed /> {/* Slow: 2000ms */}
</Suspense>
);
// User sees nothing for 2000ms — the slow feed blocks everything
}
The Popcorn Effect: Too Many Boundaries
The opposite extreme is wrapping every tiny element in its own Suspense boundary. When dozens of small components resolve at slightly different times, the page erupts with rapid skeleton-to-content transitions. This "popcorn effect" feels chaotic and can actually be perceived as worse than a single, slightly longer loading state. (I've watched users describe it as "glitchy" in usability tests — not a great look.)
// Anti-pattern: Every element has its own boundary
export default function DashboardPage() {
return (
<div>
<Suspense fallback={<Skeleton />}><MetricTitle /></Suspense>
<Suspense fallback={<Skeleton />}><MetricValue /></Suspense>
<Suspense fallback={<Skeleton />}><MetricTrend /></Suspense>
<Suspense fallback={<Skeleton />}><ChartTitle /></Suspense>
<Suspense fallback={<Skeleton />}><ChartLegend /></Suspense>
<Suspense fallback={<Skeleton />}><ChartCanvas /></Suspense>
{/* 20 more boundaries... chaos */}
</div>
);
}
The Rule of Thumb
The sweet spot: one Suspense boundary per independent data dependency, grouped by visual section. If two pieces of data always load together and display together, they should share a boundary. If they come from different sources and can be meaningfully displayed independently, they get separate boundaries.
For a typical dashboard, this usually means 3–5 Suspense boundaries: one for the metrics row, one for the main chart, one for a data table, and perhaps one for a sidebar widget. Each boundary represents a discrete unit of content that makes sense to the user as a group.
Error Handling During Streaming
So what happens when a data fetch fails inside a streaming component? Without proper error handling, a single failed API call can crash the entire page — even if the other sections loaded successfully. The App Router provides a robust file-based error handling system that pairs naturally with Suspense.
The error.js Convention
Just as loading.js creates a Suspense boundary, error.js creates an Error Boundary around a route segment. When any component in that segment throws an error, the error boundary catches it and renders your fallback UI — while the rest of the page continues to function normally.
// app/dashboard/error.tsx
"use client"; // Error boundaries must be Client Components
import { useEffect } from "react";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log the error to your error reporting service
console.error("Dashboard error:", error);
}, [error]);
return (
<div className="p-6 bg-red-50 border border-red-200 rounded-lg">
<h2 className="text-lg font-semibold text-red-800">
Something went wrong loading the dashboard
</h2>
<p className="text-red-600 mt-2">{error.message}</p>
<button
onClick={reset}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Try Again
</button>
</div>
);
}
Granular Error Boundaries with ErrorBoundary Components
For component-level error isolation — matching the granularity of your Suspense boundaries — you can use React's ErrorBoundary pattern directly. This ensures that if the chart fails to load, the metrics and orders still display normally:
// app/dashboard/page.tsx
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
function SectionError({ error, resetErrorBoundary }) {
return (
<div className="p-4 bg-red-50 rounded-lg border border-red-200">
<p className="text-red-600">Failed to load this section</p>
<button
onClick={resetErrorBoundary}
className="mt-2 text-sm text-red-800 underline"
>
Retry
</button>
</div>
);
}
export default function DashboardPage() {
return (
<div className="dashboard">
<h1>Dashboard</h1>
<ErrorBoundary FallbackComponent={SectionError}>
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel />
</Suspense>
</ErrorBoundary>
<ErrorBoundary FallbackComponent={SectionError}>
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
</ErrorBoundary>
<ErrorBoundary FallbackComponent={SectionError}>
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
</ErrorBoundary>
</div>
);
}
The not-found Pattern
Not every missing resource is an error. When a user navigates to a product that's been removed or a profile that doesn't exist, throwing an error feels wrong. The notFound() function and not-found.js convention handle this case elegantly:
// app/products/[id]/page.tsx
import { notFound } from "next/navigation";
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`);
if (res.status === 404) return null;
if (!res.ok) throw new Error("Failed to fetch product");
return res.json();
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
if (!product) {
notFound(); // Renders the nearest not-found.tsx
}
return <ProductDetails product={product} />;
}
// app/products/[id]/not-found.tsx
import Link from "next/link";
export default function ProductNotFound() {
return (
<div className="text-center py-12">
<h2 className="text-2xl font-bold">Product Not Found</h2>
<p className="text-gray-500 mt-2">
This product may have been removed or the link may be incorrect.
</p>
<Link
href="/products"
className="mt-4 inline-block text-blue-600 hover:underline"
>
Browse All Products
</Link>
</div>
);
}
Unlike error boundaries that replace the entire content area, the not-found component preserves the shared layout and navigation. Next.js automatically returns a 404 status code for search engines while rendering your custom UI for users.
Streaming and Partial Prerendering (PPR)
If you've read our guide on cache components, you already know that Partial Prerendering lets you combine static and dynamic content in the same route. Now here's where it gets interesting: streaming is the mechanism that makes PPR possible — and understanding their relationship is crucial for building the fastest possible Next.js applications.
How PPR and Suspense Work Together
With PPR enabled, Next.js prebuilds a static shell of your page at build time. This shell includes everything outside of Suspense boundaries — layouts, headers, navigation, any content that doesn't depend on request-time data. The Suspense fallbacks are also baked into this static shell.
At request time, the static shell is served instantly from the edge cache. Then the server begins executing the dynamic components inside the Suspense boundaries and streams their output to the client as each one resolves. The user sees the static shell with skeletons immediately, then watches content fill in progressively.
// next.config.ts — Enable PPR
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
ppr: true,
},
};
export default nextConfig;
// app/dashboard/page.tsx — PPR in action
import { Suspense } from "react";
import { unstable_noStore as noStore } from "next/cache";
// This component is static — prerendered at build time
function DashboardHeader() {
return (
<div className="mb-8">
<h1 className="text-3xl font-bold">Analytics Dashboard</h1>
<p className="text-gray-500">Real-time performance metrics</p>
</div>
);
}
// This component is dynamic — streamed at request time
async function LiveMetrics() {
noStore(); // Opt out of caching — this data is always fresh
const metrics = await fetch("https://api.example.com/live-metrics");
const data = await metrics.json();
return (
<div className="grid grid-cols-4 gap-4">
{data.map((m) => (
<div key={m.id} className="p-4 bg-white rounded-lg shadow">
<p className="text-sm text-gray-500">{m.label}</p>
<p className="text-2xl font-bold">{m.value}</p>
</div>
))}
</div>
);
}
export default function DashboardPage() {
return (
<div>
<DashboardHeader /> {/* Static: in the prerendered shell */}
<Suspense fallback={<MetricsSkeleton />}>
<LiveMetrics /> {/* Dynamic: streamed at request time */}
</Suspense>
</div>
);
}
Maximizing the Static Shell
The larger your static shell, the faster the initial paint. Place Suspense boundaries as close as possible to the components that actually need dynamic data. Everything outside the boundary gets included in the prerendered shell and served from the edge.
A common mistake is placing the Suspense boundary too high in the tree, which makes the static shell smaller than it needs to be. If a section has a heading and a chart, but only the chart needs dynamic data, wrap just the chart — not the heading:
// Good: Maximizes static shell
<section>
<h2>Sales Performance</h2> {/* Static */}
<p>Updated in real-time</p> {/* Static */}
<Suspense fallback={<ChartSkeleton />}>
<SalesChart /> {/* Dynamic */}
</Suspense>
</section>
// Bad: Wraps static content unnecessarily
<Suspense fallback={<SectionSkeleton />}>
<section>
<h2>Sales Performance</h2> {/* Could have been static */}
<p>Updated in real-time</p> {/* Could have been static */}
<SalesChart />
</section>
</Suspense>
Avoiding Server-Side Waterfalls
Streaming eliminates client-side waterfalls, but it's entirely possible to create server-side waterfalls that negate all your performance gains. This is the most common anti-pattern in Suspense-based architectures, and it's often invisible because the page still "works" — it just loads slowly. I've seen teams spend hours debugging this, only to realize their components were accidentally nested instead of siblings.
The Sequential Fetch Anti-Pattern
When async server components are nested — one inside another — they execute sequentially. The child doesn't begin fetching until the parent has finished:
// Anti-pattern: Sequential execution due to nesting
async function ParentComponent() {
const user = await getUser(); // Takes 200ms
return (
<div>
<h2>Welcome, {user.name}</h2>
<UserOrders userId={user.id} /> {/* Doesn't start until getUser resolves */}
</div>
);
}
async function UserOrders({ userId }: { userId: string }) {
const orders = await getOrders(userId); // Takes 300ms
// Total: 500ms (200 + 300), not 300ms
return <OrderList orders={orders} />;
}
The Fix: Parallel Fetching with Sibling Components
Restructure your components so that independent data fetches happen in siblings rather than in a parent-child chain. When sibling components within Suspense boundaries fetch data, they execute in parallel:
// Correct: Parallel execution with sibling components
export default function UserDashboard({ userId }: { userId: string }) {
return (
<div>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId={userId} />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<UserOrders userId={userId} />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations userId={userId} />
</Suspense>
</div>
);
// All three fetch in parallel — total time is max(200, 300, 150) = 300ms
}
When You Must Fetch Sequentially
Sometimes a sequential fetch is unavoidable — you need data from the first request to make the second request. In that case, use Promise.all for the independent parts and accept the sequential dependency where it exists. Don't fight the natural data dependencies of your application; just make sure you're not creating artificial ones.
// When you genuinely need sequential data
async function OrderDetails({ orderId }: { orderId: string }) {
const order = await getOrder(orderId);
// These two depend on order data but not on each other
const [shipping, invoice] = await Promise.all([
getShippingStatus(order.trackingId),
getInvoice(order.invoiceId),
]);
return (
<div>
<OrderSummary order={order} />
<ShippingTracker status={shipping} />
<InvoiceDetails invoice={invoice} />
</div>
);
}
SEO and Streaming: What Crawlers See
A common concern with streaming is how it affects search engine optimization. The good news: streaming is fully server-rendered, so it doesn't impact SEO negatively. But there are some nuances worth understanding.
How Googlebot Handles Streamed Content
Modern crawlers like Googlebot can execute JavaScript and will see the fully resolved content. But even before JavaScript execution, Next.js ensures that generateMetadata resolves completely before any UI is streamed. This means the <title>, meta description, Open Graph tags, and all other metadata are in the initial HTML response's <head> — regardless of how long the body content takes to stream.
// app/products/[id]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const product = await getProduct(id);
return {
title: product.name,
description: product.description,
openGraph: {
images: [product.imageUrl],
},
};
}
// This metadata is fully resolved before any streaming begins
Social Media Crawlers
Simpler bots like Twitterbot and Facebook's crawler that only scrape static HTML (without executing JavaScript) will still see complete metadata because it's placed in the <head> before streaming starts. Your link previews will always display correctly.
Infrastructure Considerations
Look, streaming can be silently broken by your hosting infrastructure. The most common culprit is reverse proxy buffering, which accumulates the entire streamed response before forwarding it to the client — completely defeating the purpose of streaming.
Reverse Proxy Buffering
If you deploy behind Nginx, make sure you disable response buffering for your Next.js routes:
# nginx.conf
location / {
proxy_pass http://localhost:3000;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
}
Vercel handles this automatically — streamed responses are forwarded incrementally to the client without buffering. This is one of the advantages of deploying Next.js on its native platform.
CDN Considerations
Most CDNs are designed for complete responses, not streaming. If your CDN buffers the entire response before serving it, your users won't see progressive content loading. Verify that your CDN supports HTTP chunked transfer encoding and test with real network conditions to confirm streaming works end-to-end.
A Complete Streaming Dashboard Example
Let's put everything together in a production-ready dashboard that demonstrates all the patterns we've covered. This is the kind of structure you'd actually ship in a real application:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen">
<aside className="w-64 bg-gray-900 text-white p-6">
<nav>
<ul className="space-y-2">
<li><a href="/dashboard">Overview</a></li>
<li><a href="/dashboard/analytics">Analytics</a></li>
<li><a href="/dashboard/orders">Orders</a></li>
<li><a href="/dashboard/settings">Settings</a></li>
</ul>
</nav>
</aside>
<main className="flex-1 p-8 bg-gray-50">{children}</main>
</div>
);
}
// app/dashboard/page.tsx
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { MetricsPanel } from "@/components/dashboard/metrics-panel";
import { RevenueChart } from "@/components/dashboard/revenue-chart";
import { RecentOrders } from "@/components/dashboard/recent-orders";
import { ActivityFeed } from "@/components/dashboard/activity-feed";
import {
MetricsSkeleton,
ChartSkeleton,
OrdersSkeleton,
FeedSkeleton,
} from "@/components/dashboard/skeletons";
import { SectionError } from "@/components/dashboard/section-error";
export default function DashboardPage() {
return (
<div>
<h1 className="text-3xl font-bold mb-8">Dashboard Overview</h1>
{/* Metrics row — fast API, loads first */}
<ErrorBoundary FallbackComponent={SectionError}>
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel />
</Suspense>
</ErrorBoundary>
{/* Two-column layout: chart + orders stream independently */}
<div className="grid grid-cols-2 gap-6 mt-8">
<ErrorBoundary FallbackComponent={SectionError}>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
</ErrorBoundary>
<ErrorBoundary FallbackComponent={SectionError}>
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
</ErrorBoundary>
</div>
{/* Activity feed — potentially slow, streams last */}
<div className="mt-8">
<ErrorBoundary FallbackComponent={SectionError}>
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed />
</Suspense>
</ErrorBoundary>
</div>
</div>
);
}
Notice the structure: the heading is static and renders immediately. Four independent data sources each have their own Suspense boundary paired with an Error Boundary. The grid layout is defined in static markup, so the page structure is visible from the first paint. Each section streams in as its data arrives, and if any section fails, the others remain fully functional with a retry option.
Measuring Streaming Performance
You can't improve what you don't measure. Here are the key metrics to track when optimizing streaming performance:
- TTFB (Time to First Byte): Should be under 100ms with PPR. This is the time until the static shell arrives. If TTFB is high, your edge caching or CDN may not be configured correctly.
- FCP (First Contentful Paint): The time until the first meaningful content is visible — your skeleton states. Should be nearly identical to TTFB with streaming.
- LCP (Largest Contentful Paint): The time until the largest content element is fully rendered. With streaming, this depends on your slowest critical data fetch. Target under 2.5 seconds.
- CLS (Cumulative Layout Shift): Should be under 0.1. If streaming causes layout shifts, your skeletons don't match the dimensions of the resolved content.
Use the Next.js Speed Insights integration, the Chrome DevTools Performance tab, or the web-vitals library to track these metrics in development and production. Pay special attention to CLS — it's the metric most likely to regress with streaming if your skeletons aren't carefully designed.
Summary: The Streaming Checklist
Before shipping a streaming Next.js page to production, run through this checklist:
- Route-level loading: Every route segment that fetches data has a
loading.tsxwith a meaningful skeleton. - Granular Suspense: Pages with multiple independent data sources use separate Suspense boundaries — one per visual section, not one per component.
- Error isolation: Each Suspense boundary is paired with an ErrorBoundary so failures are contained.
- No artificial waterfalls: Independent data fetches happen in sibling components, not nested ones.
- Skeleton fidelity: Every skeleton matches the dimensions of its resolved content to prevent layout shifts.
- Infrastructure verified: Reverse proxy buffering is disabled. CDN supports chunked transfer encoding.
- Metadata first:
generateMetadatais used for SEO-critical pages, ensuring metadata is in the initial response. - Metrics monitored: TTFB, FCP, LCP, and CLS are tracked in production.
Streaming isn't an advanced feature you add later — it's the default rendering model of the Next.js App Router. Every async Server Component wrapped in a Suspense boundary participates in streaming automatically. The question isn't whether to use streaming, but how to design your component boundaries and loading states to make the most of it. Get the Suspense boundaries right, pair them with thoughtful skeletons, and your application will feel fast even when the data behind it is slow.