inject RSC streams into data-heavy or security-sensitive rendering paths while leaving interactive UI untouched. The explicit model aligns RSC with existing data-fetching patterns, making caching, invalidation, and error handling predictable. You stop fighting framework defaults and start composing rendering strategies.
Core Solution
Implementing RSC streams in TanStack Start requires shifting from implicit rendering to explicit stream composition. The architecture relies on two principles: server functions as stream producers, and client hooks as stream consumers. This separation maintains clear execution boundaries while leveraging existing data-fetching infrastructure.
Step 1: Define the Server Stream Producer
Server functions in TanStack Start act as the bridge between server execution and client consumption. Instead of returning JSON or HTML, the function returns a readable stream of serialized React elements.
// server/functions/metrics.ts
import { createServerFn } from '@tanstack/react-start'
import { renderToReadableStream } from '@tanstack/react-start/rsc'
import { RevenueChart } from '../components/RevenueChart'
import { getFinancialData } from '../lib/data-fetcher'
export const fetchMetricsStream = createServerFn({ method: 'GET' }).handler(
async ({ period }: { period: 'daily' | 'weekly' | 'monthly' }) => {
const data = await getFinancialData(period)
// Serialize JSX directly into a React Flight stream
return renderToReadableStream(
<RevenueChart
dataset={data}
period={period}
config={{ interactive: false }}
/>
)
}
)
Architecture Rationale:
renderToReadableStream is the foundational React API for RSC serialization. TanStack Start re-exports it to maintain protocol compatibility.
- Returning JSX directly avoids intermediate JSON transformation. The server executes the component, resolves dependencies, and streams the structural payload.
- Explicit parameters (
period) enable precise cache key generation and stream invalidation.
Step 2: Consume the Stream on the Client
The client treats the RSC stream as a queryable resource. TanStack Query manages the fetch lifecycle, caching, and background refetching. The stream is deserialized using the client-side React Flight API.
// client/hooks/useMetrics.ts
import { useQuery } from '@tanstack/react-query'
import { createFromReadableStream } from '@tanstack/react-start/rsc'
import { fetchMetricsStream } from '../../server/functions/metrics'
export function useMetricsStream(period: 'daily' | 'weekly' | 'monthly') {
return useQuery({
queryKey: ['rsc-metrics', period],
queryFn: async () => {
const stream = await fetchMetricsStream({ period })
// Deserialize the React Flight stream into a renderable element
return createFromReadableStream(stream)
},
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes
})
}
Architecture Rationale:
createFromReadableStream reconstructs the virtual DOM tree from the serialized payload. It handles reference resolution (@1, @2, etc.) and client component injection automatically.
- Wrapping the stream in
useQuery aligns RSC with standard data-fetching patterns. Caching, retries, and background updates work identically to JSON APIs.
staleTime and gcTime prevent unnecessary stream regeneration while keeping memory footprint predictable.
Step 3: Render the Stream in the UI
The deserialized element mounts like any other React node. No hydration occurs. The tree is inserted directly into the DOM.
// client/routes/dashboard.tsx
import { useMetricsStream } from '../hooks/useMetrics'
import { InteractiveControls } from '../components/InteractiveControls'
export function DashboardRoute() {
const { data: metricsTree, isLoading, error } = useMetricsStream('weekly')
if (isLoading) return <div className="skeleton">Loading metrics...</div>
if (error) return <div className="error">Failed to load metrics</div>
return (
<section className="dashboard-layout">
{/* RSC stream mounts without hydration */}
<div className="metrics-container">{metricsTree}</div>
{/* Client component handles interactivity separately */}
<InteractiveControls />
</section>
)
}
Architecture Rationale:
- Separating RSC output from interactive components prevents boundary leakage. The server stream handles static/data-heavy rendering; client components manage state and events.
- The container
div acts as a mounting point. React reconciles the stream payload against the existing DOM without hydration overhead.
- Error and loading states are managed at the query layer, keeping the UI component declarative.
Pitfall Guide
1. Treating RSC Payloads as HTML
Explanation: Developers often expect RSC streams to contain markup. They attempt to parse or manipulate the payload as strings, which breaks the React Flight protocol.
Fix: Treat the payload as a serialized virtual DOM. Use createFromReadableStream exclusively. Never inspect or modify raw stream bytes.
2. Over-Streaming Trivial UI
Explanation: Wrapping every component in an RSC stream introduces network latency and serialization overhead. Simple UI elements render faster client-side.
Fix: Reserve RSC for data-heavy, security-sensitive, or dependency-heavy components. Use client components for interactive, state-driven, or lightweight UI.
3. Ignoring Stream Backpressure
Explanation: Large RSC payloads can overwhelm the client if streamed without pacing. The browser may freeze while deserializing massive chunks.
Fix: Implement chunked rendering or progressive disclosure. Use startTransition when mounting large streams to keep the main thread responsive.
4. Boundary Leakage
Explanation: Importing server-only modules (database clients, file system APIs) into client components causes build failures or runtime crashes.
Fix: Enforce strict file boundaries. Use use server and use client directives explicitly. Validate imports with TypeScript path aliases and build-time checks.
5. Caching Misalignment
Explanation: RSC streams are often cached incorrectly because developers treat them like JSON responses. Stream invalidation requires query key updates, not manual cache clearing.
Fix: Tie stream query keys to data dependencies. Use queryClient.invalidateQueries() when underlying data changes. Avoid static cache keys for dynamic payloads.
6. Hydration Mismatch Assumptions
Explanation: Teams expect RSC to hydrate like SSR. When interactivity fails, they assume hydration errors instead of recognizing that RSC never hydrates.
Fix: Accept that RSC mounts, it doesn’t hydrate. Attach event handlers and state exclusively to client components. Use use client boundaries for all interactive logic.
7. Debugging Blind Spots
Explanation: Console logs inside server components don’t appear in the browser. Network payloads are binary/text encoded, making manual inspection difficult.
Fix: Use React DevTools RSC tab for payload inspection. Log server execution on the backend. Test stream deserialization in isolation before integrating into routes.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Data-heavy dashboard with static charts | RSC Stream | Eliminates hydration, reduces payload, server executes heavy logic | Low network cost, moderate server compute |
| Interactive form with real-time validation | Client Component | Requires immediate state updates, event handling, and DOM manipulation | Higher client bundle, zero server overhead |
| Mixed UI (static data + interactive controls) | Hybrid RSC + Client | RSC handles data rendering; client component manages interactivity | Balanced cost, optimal performance |
| SEO-critical landing page | Traditional SSR | Full HTML delivery ensures crawler compatibility and fast FCP | Higher initial payload, predictable caching |
Configuration Template
// tanstack-start.config.ts
import { defineConfig } from '@tanstack/react-start/config'
import tsConfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
server: {
preset: 'node-server',
rsc: {
enabled: true,
// Opt-in mode: RSC streams are explicit, not default
mode: 'opt-in',
// Stream chunk size for backpressure control
chunkSize: 64 * 1024, // 64KB
},
},
vite: {
plugins: [tsConfigPaths()],
ssr: {
noExternal: ['@tanstack/react-start/rsc'],
},
},
})
Quick Start Guide
- Install dependencies:
npm install @tanstack/react-start @tanstack/react-query
- Enable RSC opt-in mode: Add
rsc: { enabled: true, mode: 'opt-in' } to your TanStack Start config.
- Create a server function: Use
createServerFn with renderToReadableStream to return JSX.
- Consume on the client: Wrap the stream in
useQuery and deserialize with createFromReadableStream.
- Mount and verify: Render the query data in a container div. Check React DevTools RSC tab to confirm payload structure and zero hydration overhead.