session = await verifySession()
if (!session?.isValid) {
redirect('/auth/sign-in')
}
return <DashboardShell tenantId={session.tenantId}>{children}</DashboardShell>
}
The layout component acts as the gatekeeper. It validates the session synchronously and immediately renders the structural shell. No user profile data, metrics, or feed content is awaited here. The browser receives the navigation, sidebar, and layout containers within milliseconds.
### Step 2: Delegate Fetching to Leaf Components
Traditional SSR hoists data fetching to the page or layout level, creating a centralized bottleneck. Streaming SSR inverts this by allowing each async Server Component to own its data requirements. This eliminates prop drilling and enables independent resolution timelines.
```typescript
// components/profile/UserProfilePanel.tsx
import { fetchUserPreferences } from '@/lib/api/users'
import { ProfileCard } from '@/components/ui/ProfileCard'
export async function UserProfilePanel({ userId }: { userId: string }) {
const preferences = await fetchUserPreferences(userId, {
next: { revalidate: 120 }
})
return <ProfileCard
displayName={preferences.name}
avatarUrl={preferences.avatar}
role={preferences.role}
/>
}
// components/metrics/SystemMetricsFeed.tsx
import { fetchSystemMetrics } from '@/lib/api/analytics'
import { MetricsGrid } from '@/components/ui/MetricsGrid'
export async function SystemMetricsFeed({ tenantId }: { tenantId: string }) {
const metrics = await fetchSystemMetrics(tenantId, {
next: { revalidate: 30 }
})
return <MetricsGrid
cpuUsage={metrics.cpu}
memoryLoad={metrics.memory}
activeConnections={metrics.connections}
/>
}
Each component declares its own data dependency and fetches it directly. The server does not coordinate these calls. They execute in parallel once their respective Suspense boundaries are reached, and each resolves on its own timeline.
Step 3: Wrap Async Sections in Suspense Boundaries
Suspense boundaries define the streaming contract. They render a fallback skeleton immediately, then replace it with the resolved component HTML when the async function completes. React handles the chunked delivery automatically.
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { UserProfilePanel } from '@/components/profile/UserProfilePanel'
import { SystemMetricsFeed } from '@/components/metrics/SystemMetricsFeed'
import { ProfileSkeleton } from '@/components/skeletons/ProfileSkeleton'
import { MetricsSkeleton } from '@/components/skeletons/MetricsSkeleton'
export default async function DashboardPage({
params
}: {
params: { userId: string; tenantId: string }
}) {
return (
<main className="dashboard-container">
<section className="profile-section">
<Suspense fallback={<ProfileSkeleton />}>
<UserProfilePanel userId={params.userId} />
</Suspense>
</section>
<section className="metrics-section">
<Suspense fallback={<MetricsSkeleton />}>
<SystemMetricsFeed tenantId={params.tenantId} />
</Suspense>
</section>
</main>
)
}
The page component contains zero data fetching logic. It only declares the structure and Suspense boundaries. React's streaming renderer (renderToPipeableStream) sends the shell HTML first. As UserProfilePanel and SystemMetricsFeed resolve, the server pushes incremental chunks containing the final markup and hydration instructions. The browser swaps skeletons without additional network requests or full page reloads.
Step 4: Implement Fault Isolation with Error Boundaries
Streaming introduces a new failure mode: partial page rendering. If a downstream API times out, the corresponding Suspense boundary must degrade gracefully without breaking the entire stream. Error Boundaries provide component-level fault isolation.
// components/ui/ErrorRecoveryBoundary.tsx
'use client'
import { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
fallback: ReactNode
children: ReactNode
onRetry?: () => void
}
interface State {
hasError: boolean
}
export class ErrorRecoveryBoundary extends Component<Props, State> {
state: State = { hasError: false }
static getDerivedStateFromError(): State {
return { hasError: true }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('[StreamError]', error.message, errorInfo.componentStack)
}
render() {
if (this.state.hasError) {
return (
<div className="error-container">
{this.props.fallback}
{this.props.onRetry && (
<button onClick={this.props.onRetry} className="retry-btn">
Retry
</button>
)}
</div>
)
}
return this.props.children
}
}
// app/dashboard/page.tsx (updated)
import { Suspense } from 'react'
import { ErrorRecoveryBoundary } from '@/components/ui/ErrorRecoveryBoundary'
import { UserProfilePanel } from '@/components/profile/UserProfilePanel'
import { SystemMetricsFeed } from '@/components/metrics/SystemMetricsFeed'
import { ProfileSkeleton } from '@/components/skeletons/ProfileSkeleton'
import { MetricsSkeleton } from '@/components/skeletons/MetricsSkeleton'
import { MetricsErrorFallback } from '@/components/fallbacks/MetricsErrorFallback'
export default async function DashboardPage({
params
}: {
params: { userId: string; tenantId: string }
}) {
return (
<main className="dashboard-container">
<section className="profile-section">
<Suspense fallback={<ProfileSkeleton />}>
<UserProfilePanel userId={params.userId} />
</Suspense>
</section>
<section className="metrics-section">
<ErrorRecoveryBoundary fallback={<MetricsErrorFallback />}>
<Suspense fallback={<MetricsSkeleton />}>
<SystemMetricsFeed tenantId={params.tenantId} />
</Suspense>
</ErrorRecoveryBoundary>
</section>
</main>
)
}
The ErrorRecoveryBoundary catches runtime errors within the async component tree. If SystemMetricsFeed throws due to a downstream 500 or timeout, the boundary renders the fallback UI while the rest of the page remains fully interactive. This prevents cascade failures and maintains user trust during partial outages.
Architecture Rationale
- Why Server Components? They execute exclusively on the server, eliminating client-side bundle size for data fetching logic. They integrate natively with React's streaming renderer.
- Why Suspense? It provides the rendering contract for progressive delivery. Without it, the server would block until every async component resolves.
- Why Independent Fetches? Centralized data aggregation creates waterfall dependencies. Decoupled fetching enables parallel execution and independent resolution timelines.
- Why Error Boundaries? Streaming introduces partial failure states. Component-level isolation ensures graceful degradation instead of page-wide crashes.
Pitfall Guide
1. Parent Component Data Hoarding
Explanation: Developers often fetch all data in the page component and pass it down as props, recreating the traditional SSR bottleneck. This defeats streaming entirely.
Fix: Remove data fetching from layout/page components. Let leaf Server Components declare and execute their own fetch calls. Use Suspense boundaries to manage loading states.
2. Overly Broad Suspense Boundaries
Explanation: Wrapping an entire page or large section in a single Suspense boundary forces all nested async components to wait for the slowest one. The skeleton remains visible until every dependency resolves.
Fix: Granularize boundaries. Each independent data source should have its own Suspense wrapper. This enables progressive enrichment rather than batched delivery.
3. Hydration Mismatch from Async State
Explanation: If a Server Component renders different content than what the client expects during hydration, React throws a mismatch error. This commonly occurs when async components rely on client-only state or environment variables.
Fix: Ensure Server Components render deterministic output based solely on props and server-side data. Use use client directives only for interactive components that require browser APIs. Validate that skeleton and final markup share identical DOM structures.
4. Ignoring Stream Backpressure and Chunk Limits
Explanation: React's streaming renderer pushes chunks continuously. If the client network is slow or the server generates chunks faster than the connection can transmit, backpressure builds, causing memory leaks or dropped chunks.
Fix: Monitor stream performance using onAllReady and onError callbacks in renderToPipeableStream. Implement chunk size limits and debounce rapid state updates. Use HTTP/2 multiplexing to handle concurrent streams efficiently.
5. Missing Retry and Recovery Logic
Explanation: When a streaming chunk fails to load, the skeleton persists indefinitely. Users have no mechanism to recover from transient network failures or downstream API timeouts.
Fix: Implement exponential backoff retry logic within Error Boundaries. Provide manual retry triggers and automatic recovery for non-critical sections. Cache successful responses to reduce retry load.
6. Over-Caching Async Components
Explanation: Aggressive caching on async Server Components can serve stale data in streaming chunks. Users see outdated metrics or profiles while the stream delivers cached HTML.
Fix: Apply granular cache controls using next: { revalidate: seconds } or next: { tags: ['profile'] }. Invalidate caches selectively using revalidateTag() or revalidatePath() when data changes. Avoid caching real-time or user-specific feeds.
7. Blocking the Main Thread with Synchronous Setup
Explanation: Developers sometimes perform heavy synchronous operations (JSON parsing, image resizing, template compilation) inside Server Components. This blocks the streaming pipeline and delays chunk delivery.
Fix: Offload CPU-intensive tasks to worker threads or background jobs. Keep Server Components lightweight. Use streaming-friendly transformations and defer heavy computation until after the shell renders.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Static marketing pages | Traditional SSR or SSG | No dynamic dependencies; simple caching | Low infrastructure cost |
| Dashboard with mixed critical/non-critical data | Streaming SSR (RSC + Suspense) | Decouples auth from content; progressive paint | Moderate server compute, lower CDN egress |
| Real-time collaborative tools | CSR with WebSockets + SSR shell | Requires instant client updates; streaming adds latency | Higher client bundle, lower server load |
| Legacy Next.js Pages Router | BFF streaming or API aggregation | Pages Router lacks native RSC/Suspense support | Requires middleware layer or external proxy |
| High-traffic public content | SSG with ISR + Edge caching | Pre-rendered HTML; instant delivery; minimal server compute | High storage, lowest compute cost |
Configuration Template
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: '2mb'
},
optimizeServerReact: true
},
headers: async () => [
{
source: '/:path*',
headers: [
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'Cache-Control',
value: 'no-cache, no-store, must-revalidate'
}
]
}
],
reactStrictMode: true,
poweredByHeader: false
}
export default nextConfig
// lib/api/fetch-wrapper.ts
import { cache } from 'react'
type FetchOptions = RequestInit & {
next?: { revalidate?: number; tags?: string[] }
timeout?: number
}
export async function secureFetch<T>(
url: string,
options: FetchOptions = {}
): Promise<T> {
const { timeout = 5000, ...fetchOptions } = options
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...fetchOptions.headers
}
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return response.json()
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error('Request timed out')
}
throw error
} finally {
clearTimeout(timeoutId)
}
}
Quick Start Guide
- Initialize Next.js App Router project: Run
npx create-next-app@latest streaming-dashboard --typescript --app. Ensure you select the App Router during setup.
- Create async leaf components: Build
UserProfilePanel.tsx and SystemMetricsFeed.tsx with independent fetch calls. Apply next: { revalidate: 60 } for cache control.
- Wrap sections in Suspense: In
app/dashboard/page.tsx, import the async components and wrap each in <Suspense fallback={...}>. Add skeleton placeholders matching the final DOM structure.
- Add Error Boundaries: Create
ErrorRecoveryBoundary.tsx with componentDidCatch. Wrap non-critical sections to isolate failures. Provide retry UI and logging.
- Test streaming behavior: Run
next dev, open DevTools Network tab, and throttle to 3G. Verify that the shell renders instantly, skeletons appear, and content streams in progressively. Simulate API failures to confirm fault isolation.