All ArticlesReact•November 6, 2026•8 min read

Zero-Bundle React Server Components: Architecture, Streaming, and Hydration Pitfalls

React Server Components can remove server-only code from the client bundle, but only if boundaries, serialization, and streaming are designed correctly. Learn the architecture, production patterns, and hydration pitfalls that separate fast RSC apps from accidental client bundles.

Laptop displaying code outdoors, ideal for remote work or freelance programming.Photo by Meet Patel on Pexels

React Server Components (RSC) are often pitched as a zero-bundle-size feature. That is directionally true, but dangerously incomplete: server components ship no component JavaScript to the browser, while every client component below a 'use client' boundary still ships, hydrates, and executes. Production RSC architecture is therefore a boundary-design problem, not a magic bundle-size switch. This guide covers the Flight architecture, streaming hydration behavior, and the pitfalls that turn a clean RSC design into a slow or broken client bundle.

The Zero-Bundle-Size Contract: What Actually Ships

An RSC executes only on the server. React serializes the rendered tree into the Flight payload, and the client runtime reconciles that payload without downloading the server component's implementation or its server-only dependencies. The zero-bundle benefit applies to the server component subtree and to libraries imported only by server modules.

The moment a module is marked with 'use client', it becomes a client boundary. That module and its client import graph are bundled, shipped, and hydrated. A common anti-pattern is putting 'use client' on a layout or page wrapper: that single directive can pull the entire interactive tree into the client bundle even when only one button needs state.

tsx
// app/products/[id]/page.tsx
import { cache, Suspense } from 'react';
import { notFound } from 'next/navigation';
import { AddToCart } from './add-to-cart';
import { Reviews } from './reviews';

type Product = { id: string; name: string; priceCents: number };

const getProduct = cache(async (id: string): Promise<Product | null> => {
  const res = await fetch(`${process.env.API_URL}/products/${id}`, {
    signal: AbortSignal.timeout(3000),
    headers: { accept: 'application/json' },
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Product fetch failed: ${res.status}`);
  return (await res.json()) as Product;
});

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const product = await getProduct(id);
  if (!product) notFound();

  return (
    <main>
      <h1>{product.name}</h1>
      <p>${(product.priceCents / 100).toFixed(2)}</p>
      <AddToCart productId={product.id} />
      <Suspense fallback={<p>Loading reviews…</p>}>
        <Reviews productId={product.id} />
      </Suspense>
    </main>
  );
}

The page component, data fetching, and cache() deduplication stay on the server. Only AddToCart and Reviews cross into client or streamed regions according to their boundaries. Reviews is still a server component, so it streams markup without adding client JavaScript. AddToCart is a client component, so its code and dependencies ship.

tsx
// app/products/[id]/add-to-cart.tsx
'use client';

import { useState } from 'react';

type AddToCartProps = { productId: string; disabled?: boolean };
type CartState = { status: 'idle' | 'pending' | 'error'; message: string | null };

export function AddToCart({ productId, disabled = false }: AddToCartProps) {
  const [state, setState] = useState<CartState>({ status: 'idle', message: null });

  async function handleAdd(): Promise<void> {
    if (state.status === 'pending') return;
    setState({ status: 'pending', message: null });

    try {
      const res = await fetch('/api/cart', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ productId }),
      });
      if (!res.ok) throw new Error(`Cart update failed: ${res.status}`);
      setState({ status: 'idle', message: 'Added' });
    } catch (error) {
      setState({
        status: 'error',
        message: error instanceof Error ? error.message : 'Unknown cart error',
      });
    }
  }

  return (
    <>
      <button onClick={handleAdd} disabled={disabled || state.status === 'pending'}>
        {state.status === 'pending' ? 'Adding…' : 'Add to cart'}
      </button>
      {state.message ? <p role='alert'>{state.message}</p> : null}
    </>
  );
}

Boundary Design in Practice

Keep 'use client' at leaves: add-to-cart controls, search filters, modals, and chart interactions. Pass serializable data from server to client, and keep data fetching above the boundary. If a client component needs to fetch after interaction, call a route handler or Server Function; do not pass arbitrary functions as props unless they are explicitly created as Server Function references.

RSC Architecture: Flight, Boundaries, and Serializable Props

Flight is React's streaming serialization protocol for server component trees. It encodes server-rendered elements, client component references, props, and Suspense holes as a sequence of rows. The browser does not hydrate server component output; it hydrates client components and uses the Flight data to place server-rendered payloads into the right slots.

This architecture has trade-offs. You save client JavaScript for server-only code, but you add server render latency and Flight payload bytes. You can stream earlier with Suspense, but every boundary introduces scheduling and fallback behavior. You can fetch data close to the component, but you must dedupe repeated requests. Zero bundle size is not zero runtime cost.

Props Are a Serialization Boundary

Props crossing from server to client must be serializable. Primitives, plain objects, arrays, promises, and supported built-ins work; functions and class instances generally do not. Event handlers belong inside the client component. If the server needs to trigger a mutation, expose a Server Function or call an API route.

tsx
// Invalid: a server component cannot pass an event handler to a client component.
type PanelProps = { onOpen: () => void };

export function Panel({ onOpen }: PanelProps) {
  return <button onClick={onOpen}>Open</button>;
}

Treat the server-client prop boundary like an API schema. Large or deeply nested objects increase Flight payload size even when they do not increase JavaScript bundle size. Select only the fields the client needs, and avoid passing secrets, database rows, or server-only class instances into client props.

Server-Only Enforcement

A server component can import a client component, but a client component must not import server-only code. The server-only package turns accidental leaks into build-time failures. Use it in data access modules, secret-bearing clients, and server utilities.

ts
import 'server-only';

export async function getInventory(sku: string): Promise<number> {
  const res = await fetch(`${process.env.INVENTORY_URL}/stock/${sku}`, {
    signal: AbortSignal.timeout(1500),
    cache: 'no-store',
  });
  if (!res.ok) throw new Error(`Inventory lookup failed: ${res.status}`);
  const body = (await res.json()) as { available: number };
  return body.available;
}

Also audit barrel files. Importing one component from an index file can pull unrelated client modules into the graph, and importing a server module from a client boundary will fail or leak depending on configuration. Measure with framework bundle analyzers and inspect the client reference manifest in production builds.

Streaming Hydration: Suspense Boundaries and Selective Hydration

Streaming SSR sends an initial HTML shell, then later chunks fill Suspense holes. RSC streaming is similar but carries Flight rows and client references. The browser can paint the shell before slower server components finish. Client components hydrate when their JavaScript is available; React can prioritize hydration around user interaction, which is what selective hydration buys you.

The practical rule: use Suspense around slow, non-critical data, not around every component. A boundary around the product price or primary call-to-action delays meaningful paint and can cause layout shift. A boundary around reviews, recommendations, or analytics panels lets the critical shell stream immediately. Fallbacks should reserve layout space and match final dimensions.

tsx
// app/products/[id]/reviews.tsx
type Review = { id: string; author: string; body: string };

export async function Reviews({ productId }: { productId: string }) {
  const res = await fetch(`${process.env.API_URL}/products/${productId}/reviews`, {
    signal: AbortSignal.timeout(4000),
    headers: { accept: 'application/json' },
  });
  if (!res.ok) throw new Error(`Reviews fetch failed: ${res.status}`);

  const reviews = (await res.json()) as Review[];
  if (reviews.length === 0) return <p>No reviews yet.</p>;

  return (
    <ul>
      {reviews.map((review) => (
        <li key={review.id}>
          <strong>{review.author}</strong>: {review.body}
        </li>
      ))}
    </ul>
  );
}

Because Reviews is a server component, this streamed region does not add client bundle bytes. A rejected fetch throws to the nearest error boundary. In production, wrap independently failing streamed regions with error boundaries so a reviews outage does not destroy the product shell.

Hydration Mismatch Pitfalls That Survive RSC

Even with RSC, client components still hydrate. Mismatches happen when the server HTML and first client render disagree. Common causes: reading window, localStorage, Date.now(), Math.random(), or locale-dependent values during render; invalid HTML nesting that the browser repairs; and conditional rendering based on typeof window at module scope.

tsx
'use client';

import { useEffect, useState } from 'react';

export function ThemeLabel() {
  const [theme, setTheme] = useState<'light' | 'dark' | null>(null);

  useEffect(() => {
    const stored = window.localStorage.getItem('theme');
    setTheme(stored === 'dark' ? 'dark' : 'light');
  }, []);

  return <span>{theme ?? '…'}</span>;
}

Do not silence mismatches with suppressHydrationWarning unless the difference is unavoidable and narrowly scoped, such as a timestamp rendered with a known tolerance. Suppression hides correctness bugs in adjacent markup. Test with production builds and slow network throttling; development warnings do not catch every mismatch.

Production Checklist for RSC and Streaming Hydration

  • Keep 'use client' boundaries at interactive leaves; audit layouts, providers, and barrel imports for accidental client graph expansion.
  • Treat props crossing the server-client boundary as a schema: no functions, class instances, or oversized objects unless explicitly supported.
  • Dedupe per-request data with cache(); use framework-specific persistent caching only with explicit revalidation and invalidation.
  • Place Suspense boundaries around slow, non-critical regions; reserve layout space in fallbacks to avoid cumulative layout shift.
  • Add error boundaries around streamed regions so a rejected fetch does not tear down the shell.
  • Install server-only in server modules and scan client bundles for environment secrets.
  • Prefer stable, deterministic render output; move browser APIs and time-dependent values into effects or client-only components.
  • Monitor Flight payload size and TTFB, not only JavaScript bundle size.

Conclusion

RSC can genuinely remove server component code from the client bundle, but the win is earned by boundary discipline, serializable contracts, and deliberate streaming. Treat 'use client' as a budget decision, Suspense as a layout and prioritization tool, and hydration as a correctness problem. Teams that do this get faster shells, smaller client graphs, and fewer production surprises.

Sources & Technical References

1 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles
Zero-Bundle React Server Components: RSC Streaming Guide | Prasanga Pokharel