Next.js App Router Streaming Patterns
Modern SaaS dashboards must deliver visible content in under 200 ms while simultaneously resolving user-specific data that can take seconds to fetch. The Next.js App Router solves this tension through a chunked, stream-based rendering model built on React Server Components (RSC) and the React Flight Protocol. Instead of blocking the browser until the entire component tree resolves, the server serialises each completed subtree into a discrete Flight chunk and sends it over Transfer-Encoding: chunked the moment it is ready. The result is a page whose shell, navigation, and above-the-fold content appear immediately, while slower data-dependent panels stream in independently β all within the broader Framework-Specific Islands & Streaming SSR paradigm that eliminates the monolithic hydration waterfall.
Concept Definition & Scope
Next.js App Router streaming is the runtime orchestration layer that converts an async React Server Component tree into an ordered sequence of HTML and RSC payload chunks, each associated with an explicit hydration boundary that tells the client runtime exactly where to attach JavaScript and when.
In scope: RSC serialisation, <Suspense> boundary placement, loading.tsx route segments, use client directive semantics, parallel data fetching via fetch/cache, tag-based revalidation, and edge runtime configuration.
Out of scope: Client-side router transitions (these bypass SSR entirely), React Native streaming, and full-client rendering via create-next-app without the app/ directory.
This page focuses on the App Routerβs streaming mechanics. For the broader question of when partial hydration makes more sense than full RSC streaming, see the comparison section below.
Streaming Architecture: How the Runtime Executes It
The following diagram shows the three-zone model the App Router uses: a static shell zone that serialises instantly, a streaming zone where <Suspense> boundaries hold open the HTTP connection, and a client hydration zone where use client components activate.
The key insight is that boundaries A and B resolve concurrently on the server β neither blocks the other. The client receives chunks in whichever order the server completes them, and React inserts each chunk into the correct DOM position using the <!--$--> / <!--/$--> Flight markers embedded in the initial HTML.
Technical Mechanics
RSC Serialisation and the Flight Protocol
When the server renders an async RSC, React does not wait for all promises to settle before writing to the HTTP response. Instead it:
- Serialises the synchronous portions of the tree into HTML and writes them immediately.
- Encodes each pending
<Suspense>boundaryβs fallback into the same initial chunk. - As each async component resolves, serialises its output into a new Flight chunk and appends it to the open HTTP response, accompanied by a small inline
<script>tag that tells the client runtime where to splice the new markup.
This means TTFB is determined solely by the synchronous shell, not by your slowest database query.
// app/dashboard/page.tsx
// RSC (no directive = server-only by default)
import { Suspense } from 'react';
import { fetchMetrics, fetchRecentActivity } from '@/lib/data';
// Async RSC: React Flight serialises this independently
async function MetricsPanel() {
const metrics = await fetchMetrics(); // does NOT block the shell
return (
<div className="metrics-grid">
{metrics.map(m => (
<div key={m.id} className="metric-card">
<span className="metric-value">{m.value}</span>
<span className="metric-label">{m.label}</span>
</div>
))}
</div>
);
}
async function ActivityFeed() {
const activity = await fetchRecentActivity(); // parallel with MetricsPanel
return (
<ul className="activity-list">
{activity.map(a => (
<li key={a.id}>{a.description}</li>
))}
</ul>
);
}
export default function DashboardPage() {
return (
<main>
{/* Shell: in the browser before either fetch resolves */}
<h1>Dashboard Overview</h1>
{/* Boundary A: streams its own Flight chunk when MetricsPanel settles */}
<Suspense fallback={<div className="skeleton-panel" aria-busy="true" />}>
<MetricsPanel />
</Suspense>
{/* Boundary B: streams independently β no waterfall between A and B */}
<Suspense fallback={<div className="skeleton-list" aria-busy="true" />}>
<ActivityFeed />
</Suspense>
</main>
);
}
Each <Suspense> wrapper is an independent streaming boundary. When MetricsPanel resolves at 400 ms and ActivityFeed resolves at 700 ms, the browser receives and renders them in that order without any coordination overhead.
Comparison: Streaming Approaches Across Rendering Models
| Dimension | Next.js App Router (RSC) | Astro Islands | SvelteKit Islands | Qwik Resumability |
|---|---|---|---|---|
| Streaming granularity | Per <Suspense> boundary |
Build-time; no runtime streaming | Per load() function per route |
Resumable β no hydration step |
| Client JS bundle | Only use client subtrees |
Per island with client directive | Per component with browser lifecycle |
Near-zero; serialised closures |
| Dynamic user data | Excellent β runtime fetch + tag revalidation | Limited β suited to static/semi-static | Good β server load functions per route | Excellent β reactive signals |
| DX complexity | Medium β boundary placement requires care | Low β explicit directives per island | Low β clear server/client split | High β new mental model |
| TTI on cold visit | Fast β shell before data resolves | Very fast β minimal JS | Fast β selective hydration | Very fast β no replay |
| Cache granularity | Per-fetch tag revalidation | Build-time per route | Per load function |
N/A β no cache layer |
Use the App Routerβs streaming model when your pages carry significant runtime personalisation β logged-in dashboards, live feeds, user-specific recommendations. Prefer Astro Islands when the majority of the page is static and only a few widgets need JavaScript.
Step-by-Step Integration Pattern
Step 1 β Enable the App Router and Streaming Defaults
Next.js 13.4+ streams by default when you use the app/ directory. Confirm your next.config.ts:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// Do NOT disable serverExternalPackages unless a specific npm package requires it β
// disabling it forces some server logic into the client bundle, breaking RSC streaming.
experimental: {},
};
export default nextConfig;
Step 2 β Set Route Segment Configs
Control caching and streaming behaviour per route using segment config exports:
// app/dashboard/page.tsx (top of file, before any imports)
// Force server-render on every request β required for fully personalised pages.
// Omit this on pages that can be partially cached.
export const dynamic = 'force-dynamic';
// OR use ISR: revalidate the streamed output every 60 seconds
// export const revalidate = 60;
// Target the Edge runtime for lower TTFB on global routes.
// Remove this if you use Node.js-only APIs (fs, crypto, etc.).
export const runtime = 'edge';
Step 3 β Add loading.tsx for Automatic Route-Level Suspense
Place a loading.tsx file alongside page.tsx. Next.js automatically wraps the segment in <Suspense> and renders loading.tsx as the fallback:
// app/dashboard/loading.tsx
// Rendered as the Suspense fallback while the async page.tsx resolves.
// Keep this lightweight β it is the first thing the user sees.
export default function DashboardSkeleton() {
return (
<div aria-busy="true" aria-label="Loading dashboard">
<div className="skeleton-header" />
<div className="skeleton-panel" />
<div className="skeleton-list" />
</div>
);
}
Step 4 β Parallel Data Fetching with Request Memoisation
Use Reactβs cache() to deduplicate identical fetches across multiple RSC invocations in the same render pass. Initiate fetches in parallel by calling them before any await:
// lib/data.ts
import { cache } from 'react';
// cache() memoises within a single server request β two components calling
// getProduct('42') in the same render will share one network round-trip.
export const getProduct = cache(async (id: string) => {
const res = await fetch(`/api/products/${id}`, {
// next.tags lets you surgically invalidate this fetch later
next: { tags: [`product-${id}`], revalidate: 3600 },
});
if (!res.ok) return null;
return res.json() as Promise<Product>;
});
// app/products/[id]/page.tsx
import { getProduct } from '@/lib/data';
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
// Pre-render high-traffic product pages at build time;
// unknown IDs will be server-rendered on demand and cached.
return [{ id: '1' }, { id: '2' }, { id: '3' }];
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params; // params is async in Next.js 15+
const product = await getProduct(id);
if (!product) notFound();
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
</article>
);
}
Call revalidateTag('product-42') inside a Server Action to invalidate only the affected fetch without clearing the entire route cache.
Step 5 β Enforce the use client Boundary
Mark interactive subtrees with 'use client' and pass only JSON-serialisable props across the boundary:
// components/InteractiveChart.tsx
'use client'; // Marks the hydration entry point for this subtree
import { useState, useEffect } from 'react';
interface ChartData {
labels: string[];
values: number[];
}
export default function InteractiveChart({
initialData,
}: {
initialData: ChartData; // Must be JSON-serialisable β no Date, Map, Set, or class instances
}) {
const [data, setData] = useState(initialData);
// isHydrated guards against a flash of unstyled content while streaming completes
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
setIsHydrated(true);
// Safe to attach WebSocket or polling here β runs only in the browser
}, []);
if (!isHydrated) {
// Matches the server-rendered placeholder exactly to avoid layout shift (CLS)
return <div className="chart-placeholder" aria-label="Loading chart" />;
}
return (
<canvas
aria-label="Interactive metrics chart"
data-values={JSON.stringify(data.values)}
/>
);
}
// app/dashboard/page.tsx β consuming the client component from an RSC
import { Suspense } from 'react';
import InteractiveChart from '@/components/InteractiveChart';
import { fetchChartData } from '@/lib/data';
async function ChartSection() {
const chartData = await fetchChartData();
// chartData is serialised here on the server; only JSON crosses the boundary
return <InteractiveChart initialData={chartData} />;
}
export default function DashboardPage() {
return (
<main>
<Suspense fallback={<div className="chart-placeholder" aria-busy="true" />}>
<ChartSection />
</Suspense>
</main>
);
}
Measurement & Validation
Confirm that streaming is working correctly before shipping. The cross-boundary prop passing checks below complement the streaming-specific ones here.
Network Tab: Verify Chunked Delivery
- Open Chrome DevTools β Network tab.
- Disable cache and set throttling to Slow 4G.
- Navigate to your route. Filter by Doc.
- Select the document request and open the Headers pane. Confirm
Transfer-Encoding: chunkedis present. - Open the Response pane. Scroll down β you should see
<!--$-->and<!--/$-->comment markers delimiting each streamed<Suspense>boundary, with resolved markup appearing below each pair of markers as data arrives.
Performance Tab: Hydration Timeline
// Add to _app or layout.tsx during development only
if (typeof window !== 'undefined') {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name.startsWith('react-')) {
console.log(`[Hydration] ${entry.name}: ${entry.duration.toFixed(1)}ms`);
}
}
});
observer.observe({ type: 'measure', buffered: true });
}
Target benchmarks for production routes:
| Metric | Target | Measurement |
|---|---|---|
| TTFB | < 200 ms | Network waterfall / performance.getEntriesByType('navigation')[0].responseStart |
| Hydration delta (per boundary) | < 100 ms | React DevTools Profiler β Commit chart |
| Main-thread blocking (TBT) | < 200 ms total | Lighthouse CI --preset=desktop |
| CLS | < 0.1 | Skeleton dimensions must match resolved content exactly |
Lighthouse CI Assertion
# lighthouserc.yml
ci:
assert:
assertions:
first-contentful-paint: [warn, { maxNumericValue: 1800 }]
largest-contentful-paint: [error, { maxNumericValue: 2500 }]
total-blocking-time: [error, { maxNumericValue: 200 }]
cumulative-layout-shift: [error, { maxNumericValue: 0.1 }]
Failure Modes
1 β Excessive Suspense Nesting Fragments the Payload
Nesting more than three or four <Suspense> levels multiplies the number of Flight chunks, increases client-side reconciliation time, and makes it harder to reason about loading states.
Symptom: The Performance tab shows many small Evaluate Script spikes instead of a few larger ones. TTFB is fast but Time to Interactive is slow.
Fix: Flatten boundaries. Use loading.tsx for coarse route-level fallbacks and reserve nested boundaries only for genuinely data-dependent, non-critical UI blocks like comment threads or analytics charts.
// Before: three levels of nesting for no performance gain
<Suspense fallback={<OuterSkeleton />}>
<OuterPanel>
<Suspense fallback={<InnerSkeleton />}>
<InnerWidget>
<Suspense fallback={<DeepSkeleton />}>
<DeepData />
</Suspense>
</InnerWidget>
</Suspense>
</OuterPanel>
</Suspense>
// After: two levels β outer covers the whole panel, inner only for the slow part
<Suspense fallback={<OuterSkeleton />}>
<OuterPanel>
<InnerWidget>
<Suspense fallback={<DeepSkeleton />}>
<DeepData />
</Suspense>
</InnerWidget>
</OuterPanel>
</Suspense>
2 β Non-Serialisable Props Cause Hydration Mismatches
Passing Date, Map, Set, BigInt, or class instances as props across a use client boundary causes a hydration mismatch error because JSON serialisation silently drops these types.
Symptom: Error: Hydration failed because the server rendered HTML didn't match the client. in the browser console, often immediately after a boundary resolves.
Fix: Serialise on the server, reconstruct on the client.
// β Breaks: Date is not JSON-serialisable
<DateDisplay createdAt={new Date(post.createdAt)} />
// β
Fixed: pass ISO string across the boundary, reconstruct in the client component
<DateDisplay createdAt={new Date(post.createdAt).toISOString()} />
// components/DateDisplay.tsx
'use client';
export default function DateDisplay({ createdAt }: { createdAt: string }) {
// Reconstruct the Date safely on the client
const date = new Date(createdAt);
return <time dateTime={createdAt}>{date.toLocaleDateString()}</time>;
}
3 β Unbounded revalidate: 0 on High-Traffic Routes
Setting revalidate: 0 (equivalent to dynamic = 'force-dynamic') on every fetch call disables the Data Cache entirely. Under traffic spikes this causes every concurrent request to issue its own upstream fetch, collapsing cache hit rates and spiking origin latency.
Fix: Apply revalidate: 0 only to fetches that truly require per-request freshness (session tokens, personalised pricing). Use tag-based revalidation for everything else:
// For user-specific data that must be fresh on every request
const session = await fetch('/api/session', { cache: 'no-store' });
// For shared product data β cache for one hour, invalidate surgically
const product = await fetch(`/api/products/${id}`, {
next: { tags: [`product-${id}`], revalidate: 3600 },
});
// In a Server Action after a product update:
// revalidateTag(`product-${updatedId}`); β only this product's cache entry clears
Related
- Implementing Suspense Boundaries in Next.js 14 β step-by-step boundary placement for specific Next.js 14 API changes, including the new
paramsasync API anduse cachedirective. - Astro Islands and Client Directives β contrasting approach where hydration granularity is declared statically at build time via
client:*directives rather than at runtime via<Suspense>. - SvelteKit Component Islands β SvelteKitβs server
loadfunctions and+page.server.tsmodel compared to RSCβs inline async components. - Cross-Boundary Prop Passing β serialisation rules and patterns for safely moving data from server RSCs into client components.
- Understanding Partial Hydration β foundational concept behind all selective-hydration approaches, including RSC streaming.
β Back to Framework-Specific Islands & Streaming SSR