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.

Next.js App Router Streaming Zones Diagram showing three zones: Server Shell (renders immediately), Streaming Zone (Suspense boundaries, async RSC), and Client Hydration Zone (use client components). Arrows show the React Flight Protocol carrying chunks from server to client over Transfer-Encoding: chunked. SERVER SHELL renders immediately <html> / <body> / <header> layout.tsx (RSC, static) loading.tsx fallback Chunk 1 β†’ browser in <100ms STREAMING ZONE <Suspense> boundaries Boundary A async MetricsPanel() Chunk 2 β†’ ~400ms Boundary B async ActivityFeed() Chunk 3 β†’ ~700ms React Flight Protocol Transfer-Encoding: chunked CLIENT HYDRATION 'use client' components InteractiveChart (client JS) FilterDropdown (client JS) hydrated after Chunk arrives; no full-page block

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:

  1. Serialises the synchronous portions of the tree into HTML and writes them immediately.
  2. Encodes each pending <Suspense> boundary’s fallback into the same initial chunk.
  3. 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

  1. Open Chrome DevTools β†’ Network tab.
  2. Disable cache and set throttling to Slow 4G.
  3. Navigate to your route. Filter by Doc.
  4. Select the document request and open the Headers pane. Confirm Transfer-Encoding: chunked is present.
  5. 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

← Back to Framework-Specific Islands & Streaming SSR