Skip to content

3.2 — Server Components & client boundaries

Module 3 · Lesson 2 · 🟡 Intermediate · ~40 min

What you'll learn

  • What actually crosses the server/client boundary, and what it costs
  • Where to place 'use client' — the patterns that shrink bundles by hundreds of KB
  • RSC payload size: the metric nobody watches until it's 400 KB
  • Refactoring a real PDP from 734 KB to 260 KB

This lesson fixes Aurora's structural problem #2.


The model in one picture

SERVER                                        │  CLIENT
<ProductPage>              Server Component   │
  ├─ fetches data, renders to RSC payload     │
  ├─ ships ZERO JavaScript                    │
  │                                           │
  ├─ <ProductImages>       Server Component   │
  │     └─ renders <img> tags into HTML       │
  │                                           │
  ├─ <ProductDescription>  Server Component   │
  │     └─ renders HTML, sanitized on server  │
  │                                           │
  └─ <AddToCartButton>     'use client' ──────┼──▶ downloaded, hydrated, interactive
        │                                     │      + everything it imports
        └─ <Price/> passed as a prop ─────────┼──▶ serialized into the RSC payload
              (rendered on the server,        │
               shipped as data, no JS)        │

Three rules that follow:

  1. Server Components ship no JavaScript. Their imports never reach the browser.
  2. 'use client' marks a boundary, and everything imported below it becomes client code. It is contagious down the import graph — but not through children/props.
  3. Props crossing the boundary must be serializable, and they're serialized into the RSC payload, which is inlined into the HTML. Big props = big HTML.

The contagion problem

// ❌ Aurora's "before": a client provider at the root
// app/providers.tsx
'use client';
import { CartProvider } from '@/lib/cart';               // + zustand + immer
import { AnalyticsProvider } from '@/lib/analytics';     // + vendor SDK
import { I18nProvider } from '@/lib/i18n';               // + full i18n runtime + all locales
import { ThemeProvider } from '@aurora/ui';              // + design system barrel

export function Providers({ children }) {
  return (
    <ThemeProvider>
      <I18nProvider><AnalyticsProvider><CartProvider>
        {children}
      </CartProvider></AnalyticsProvider></I18nProvider>
    </ThemeProvider>
  );
}

// app/layout.tsx
<Providers>{children}</Providers>

Every route now ships the cart store, analytics SDK, i18n runtime, and design‑system barrel — including the homepage, which needs none of them. That's Aurora's 61% shared chunk.

Two fixes, applied together:

Fix 1 — children passes through a client boundary without becoming client code

This is the most important RSC pattern, and it's not obvious.

// ✅ The provider is a Client Component, but `children` is still rendered
//    on the SERVER and passed in as an already-rendered tree.
//    Server Components inside `children` stay on the server.
// app/layout.tsx (Server Component)
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>
          {children}   {/* ← rendered on the server; NOT pulled into the client bundle */}
        </Providers>
      </body>
    </html>
  );
}

The mental model: 'use client' is contagious through import, not through props. A Client Component can render Server Components as long as they arrive as props (usually children).

Fix 2 — Don't put providers at the root if only some routes need them

// ✅ Scope providers to the routes that use them
app/
├── layout.tsx                 ← minimal: html, body, fonts. No providers.
├── (marketing)/
│   └── layout.tsx             ← no cart, no i18n runtime
├── (shop)/
│   ├── layout.tsx             ← CartProvider lives here
│   ├── c/[slug]/page.tsx
│   └── p/[slug]/page.tsx
└── checkout/
    └── layout.tsx             ← its own minimal provider set

Route groups let you scope client runtime cost to the routes that actually need it. At Aurora this alone removed 94 KB from the marketing and homepage routes.


Push the boundary down

The general refactor: find the smallest interactive leaf and mark only that.

// ❌ Before: whole card is client because of one button
'use client';
import { formatPrice } from '@/lib/price';        // pulled into the bundle
import { trackEvent } from '@/lib/analytics';     // pulled into the bundle
import Image from 'next/image';

export function ProductCard({ product }: { product: Product }) {
  const [wishlisted, setWishlisted] = useState(false);
  return (
    <article>
      <Image src={product.image} alt={product.name} width={400} height={533} sizes="..." />
      <h3>{product.name}</h3>
      <p>{formatPrice(product.price)}</p>
      <p>{product.shortDescription}</p>
      <Badge>{product.badge}</Badge>
      <button onClick={() => { setWishlisted(!wishlisted); trackEvent('wishlist', product.id); }}>
        {wishlisted ? '♥' : '♡'}
      </button>
    </article>
  );
}
// ✅ After: card is a Server Component; only the heart is client
// components/product-card.tsx  (no 'use client')
import Image from 'next/image';
import { formatPrice } from '@/lib/price';        // stays on the server
import { WishlistButton } from './wishlist-button';

export function ProductCard({ product }: { product: Product }) {
  return (
    <article>
      <Image src={product.image} alt={product.name} width={400} height={533} sizes="..." />
      <h3>{product.name}</h3>
      <p>{formatPrice(product.price)}</p>
      <p>{product.shortDescription}</p>
      <Badge>{product.badge}</Badge>
      {/* Pass only the primitives the client leaf needs */}
      <WishlistButton productId={product.id} initialWishlisted={product.isWishlisted} />
    </article>
  );
}

// components/wishlist-button.tsx
'use client';
export function WishlistButton({ productId, initialWishlisted }: {
  productId: string; initialWishlisted: boolean;
}) {
  const [wishlisted, setWishlisted] = useState(initialWishlisted);
  return (
    <button
      aria-pressed={wishlisted}
      aria-label={wishlisted ? 'Remove from wishlist' : 'Add to wishlist'}
      onClick={() => { setWishlisted((w) => !w); track('wishlist', productId); }}
    >
      {wishlisted ? '♥' : '♡'}
    </button>
  );
}

On a PLP rendering 48 cards, this moves formatPrice, the analytics module, the badge logic, and all the card markup off the client entirely. −38 KB of JS and 48 fewer hydration roots.


The RSC payload: the metric nobody watches

Server Components produce a serialized payload (the "flight" data) that's inlined into the HTML in self.__next_f.push(...) calls. It contains everything needed to reconstruct the tree on the client, including every prop passed to a Client Component.

The classic mistake:

// ❌ The entire product object — 40 fields, including a 12KB description,
//    full variant matrix, and every image URL — is serialized into the HTML
//    even though the button uses two fields.
<AddToCartButton product={product} />
// ✅ Pass only what crosses the boundary
<AddToCartButton productId={product.id} variantId={selectedVariant.id} inStock={variant.inStock} />

On a PLP passing full product objects for 48 products, Aurora was shipping 310 KB of RSC payload — larger than the HTML itself, and paid on every navigation.

Measure it:

# RSC payload size for a route (the ?_rsc= flight response)
curl -s 'https://www.auroramarket.com/p/wool-overshirt-navy?_rsc=1' \
  -H 'RSC: 1' -H 'Accept-Encoding: br' | wc -c

# How much of the HTML is inlined flight data?
curl -s https://www.auroramarket.com/p/wool-overshirt-navy \
  | grep -o 'self.__next_f.push' | wc -l
// scripts/check-rsc-payload.mjs — gate it in CI
const BUDGET_KB = { '/p/[slug]': 60, '/c/[slug]': 80, '/': 40 };

for (const [route, url] of ROUTES) {
  const res = await fetch(url, { headers: { RSC: '1' } });
  const kb = (await res.arrayBuffer()).byteLength / 1024;
  const budget = BUDGET_KB[route];
  if (kb > budget) {
    console.error(`❌ RSC payload ${route}: ${kb.toFixed(1)}KB > ${budget}KB`);
    process.exitCode = 1;
  }
}

Rules for keeping the payload small:

  1. Pass primitives and small objects, never whole entities.
  2. Strip fields at the data layer, close to the fetch — a toClientProduct() mapper.
  3. Watch for dates: Date objects serialize verbosely. Pass epoch numbers or ISO strings.
  4. Don't pass functions (you can't) or class instances (you can't).
  5. Big text belongs in HTML, not in props. A product description rendered by a Server Component appears once in the HTML. Passed as a prop to a Client Component, it appears twice.
// lib/to-client-product.ts — an explicit boundary mapper
export type ClientProduct = {
  id: string;
  variantId: string;
  price: number;
  currency: string;
  inStock: boolean;
};

// Explicit and greppable. When someone adds a field, it's a visible diff.
export function toClientProduct(p: Product, variant: Variant): ClientProduct {
  return {
    id: p.id,
    variantId: variant.id,
    price: variant.price,
    currency: variant.currency,
    inStock: variant.inventory > 0,
  };
}

Composition patterns

Server data → client interactivity

// Server Component fetches; client leaf handles interaction
export default async function ProductPage({ params }) {
  const { slug } = await params;
  const product = await getProduct(slug);

  return (
    <>
      <ProductGallery images={product.images} />            {/* server */}
      <ProductDescription html={product.descriptionHtml} /> {/* server */}
      <VariantSelector                                      {/* client */}
        variants={product.variants.map(toClientVariant)}
        productId={product.id}
      />
    </>
  );
}

Client shell → server content via children

// components/tabs.tsx
'use client';
export function Tabs({ labels, panels }: { labels: string[]; panels: React.ReactNode[] }) {
  const [active, setActive] = useState(0);
  return (
    <>
      <div role="tablist">
        {labels.map((l, i) => (
          <button key={l} role="tab" aria-selected={i === active} onClick={() => setActive(i)}>
            {l}
          </button>
        ))}
      </div>
      {/* panels are server-rendered trees passed in as props */}
      <div role="tabpanel">{panels[active]}</div>
    </>
  );
}

// app/p/[slug]/page.tsx — Server Component
<Tabs
  labels={['Details', 'Shipping', 'Reviews']}
  panels={[
    <ProductDetails key="d" product={product} />,       {/* server-rendered */}
    <ShippingInfo key="s" region={region} />,           {/* server-rendered */}
    <Reviews key="r" productId={product.id} />,         {/* server-rendered */}
  ]}
/>

All three panels render on the server. The client bundle contains only the tab‑switching logic — a few hundred bytes instead of three components' worth of code.

Trade‑off: all three panels' markup ships in the initial HTML, even if the user only opens one. Good when panels are small and you want instant switching; use next/dynamic for the panel content when panels are heavy. Measure both.

The server-only / client-only guards

// lib/db.ts — makes accidental client import a BUILD error instead of a leaked secret
import 'server-only';

export async function queryCatalog(sql: string) {
  return pool.query(sql);   // DATABASE_URL would otherwise be bundled 💀
}
// lib/local-storage.ts
import 'client-only';

Install both packages and use them in every module that must not cross. This is a security control as much as a performance one — the failure mode is a database URL in a client bundle.


Refactoring Aurora's PDP: the real numbers

Step Change JS after
Baseline 734 KB
1 Move providers out of root layout into (shop) route group 671 KB
2 children pass‑through so page content stays server‑side 619 KB
3 ProductCard → Server Component, client leaf for wishlist 581 KB
4 Gallery: server‑render slide 1, client only for controls 548 KB
5 Replace design‑system barrel with deep imports 462 KB
6 Dependency swaps (see 4.3) 341 KB
7 Facade reviews + chat (2.3) 268 KB
8 next/dynamic for below‑fold configurator & size guide 247 KB

Steps 1–5 are pure boundary hygiene: −272 KB with no dependency changes and no feature loss. That's the point of this lesson.

Field impact: p75 INP 320 ms → 195 ms, LCP −0.6 s (less JS competing with the image for bandwidth and main thread).


Common mistakes

Mistake Cost
'use client' at the root/providers Entire app becomes client code
Passing whole entities to Client Components Bloated RSC payload, shipped twice
Not using the children pass‑through pattern Server Components forced into the client tree
'use client' on a component "because it has a hook" — when the hook could live in a leaf Contagion
Importing a server util into a Client Component Bundles server code (and possibly secrets)
Assuming Server Components can't be composed with client ones Leads to premature 'use client' everywhere
Never measuring the RSC payload Silent 300 KB regressions
useEffect to fetch data that a Server Component could fetch Client waterfall + more JS

Lab 3.2 — Boundary audit

  1. Census:
    rg -l "^'use client'" app components | wc -l
    rg -l "^'use client'" app components   # look at the list — how many are leaves?
    
  2. Find the highest boundary. The 'use client' file closest to the root is your biggest opportunity. Ask: does it need to be client, or does it just render client things?
  3. Apply the children pattern to it. Re‑measure First Load JS per route.
  4. RSC payload: measure it for your three biggest routes. Anything over 80 KB, find the fat prop.
  5. Add boundary mappers (toClientX) for the two biggest offenders.
  6. Add import 'server-only' to every data‑access module.
  7. Gate the 'use client' count and the RSC payload size in CI as ratchets.

Checklist

  • Root layout has no 'use client' and no providers that aren't universally needed
  • Providers scoped by route group
  • children pass‑through used wherever a client shell wraps server content
  • 'use client' only on interactive leaves
  • Explicit boundary mappers; no whole entities crossing
  • RSC payload measured and budgeted per route
  • server-only on every data‑access module; client-only where relevant
  • 'use client' file count ratcheted in CI

Next: 3.3 Streaming & Suspense