open atlas
↑ Back to track
React patterns, senior RXP · 10 · 01

Server vs client components

In the App Router, Server Components are the default — no client JS, direct data access — and Client Components opt in with 'use client' only for interactivity; default to server and push the boundary down to the interactive leaves.

RXP Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

In the React track you wrote components that all ran in the browser. The App Router changes the default underneath you: a component file is now a Server Component unless you say otherwise. It executes once on the server, ships its output — not its code — to the client, and can await your database directly in the function body. No useState, no useEffect, no onClick — and no bytes in the bundle.

The whole boundary pattern is one decision repeated all over the tree: does this component need to run in the browser? Get that decision right and your client bundle stays small while data access stays on the server. Get it wrong — one 'use client' in the wrong place — and you drag an entire subtree across the boundary, re-creating the all-client app the RSC model was meant to retire.

Goal

After this lesson you can tell a Server Component from a Client Component in the App Router; state precisely what each can and cannot do (data access and zero JS vs. state, effects, events, and browser APIs); place the 'use client' boundary at the interactive leaves rather than the root; and recognize the failure mode where a top-of-tree 'use client' turns the whole app into a client bundle and throws away the RSC benefit.

1

Server Components are the default — they run on the server, ship zero JS, and can await data directly. There is no directive to opt in; a plain component in the App Router is a Server Component. It runs during the request, can be async, and can read your data layer inline. Its rendered output is serialized to the client; its source code never is.

// app/users/page.tsx — no directive = Server Component
import { db } from "~/lib/db";

export default async function UsersPage() {
  const users = await db.user.findMany(); // runs on the server, secrets stay server-side
  return (
    <ul>
      {users.map((u) => <li key={u.id}>{u.name}</li>)}
    </ul>
  );
}

What it cannot do: hold state, run effects, attach event handlers, or touch browser APIs (window, localStorage). There is no client runtime for it — it already ran.

2

Client Components opt in with 'use client' — that’s where state, effects, events, and browser APIs live. The directive at the top of a file marks it (and everything it imports that isn’t already a Server Component) as code that ships to the browser and hydrates there. You reach for it only when a component genuinely needs interactivity.

// app/users/SearchBox.tsx
"use client";
import { useState } from "react";

export function SearchBox({ onChange }: { onChange: (q: string) => void }) {
  const [q, setQ] = useState("");           // state — needs the client
  return (
    <input
      value={q}
      onChange={(e) => { setQ(e.target.value); onChange(e.target.value); }} // event handler — needs the client
    />
  );
}

What it cannot do: await your database in the body or import server-only secrets. A Client Component runs in the browser, so it must fetch through an API or receive data as props from a Server Component above it.

3

The boundary is directional and viral: 'use client' marks an entry point, and everything imported below it becomes client code too. 'use client' is not “this one component is interactive” — it’s “the client bundle starts here”. Every module this file imports (that isn’t itself a Server Component boundary) is pulled into the browser bundle. That is why placement is the whole game: the directive’s position decides how much of your tree ships as JavaScript.

// WRONG instinct: mark the page so a child can use state
"use client";              // ⛔ now the page AND all its children are client code
import { Chart } from "./Chart";        // heavy — now in the bundle
import { Table } from "./Table";        // static — needlessly in the bundle
export default function Dashboard() { /* ...one button needed state... */ }

One component needed a click handler, so the entire dashboard — chart library, static table, all of it — became a client bundle and lost server data access. The fix is never to move the boundary up; it’s to push it down.

4

Default to Server Components and push 'use client' down to the interactive leaves. Keep parents on the server, hand interactive bits to small client leaves, and pass server-rendered content into them as children or props. A Client Component can render Server Components passed as children — they were already rendered on the server — so the client boundary stays a thin shell around the truly interactive part.

// Server Component parent — data + composition stay on the server
export default async function Dashboard() {
  const data = await getMetrics();
  return (
    <section>
      <Chart data={data} />                {/* Server Component: zero JS */}
      <Collapsible>                        {/* tiny Client leaf for the toggle */}
        <Table rows={data.rows} />         {/* Server Component, passed as children */}
      </Collapsible>
    </section>
  );
}
// Collapsible.tsx — the ONLY client code here
"use client";
import { useState } from "react";
export function Collapsible({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen((o) => !o)}>{open ? "Hide" : "Show"}</button>
      {open && children}                   {/* server-rendered Table, no extra JS */}
    </>
  );
}

Now only Collapsible ships JS. The chart, the table, and the data fetch stay on the server. The boundary is a leaf, not a root.

Worked example

A product page that ships too much JS, fixed by moving one directive. The page needs server data (the product), and one interactive bit (an add-to-cart button with a quantity stepper). The first attempt marks the page itself as a client component so the stepper can use state:

// BEFORE — app/product/[id]/page.tsx
"use client";                              // ⛔ boundary at the root
import { useState } from "react";
import { ProductGallery } from "./ProductGallery"; // heavy, static
import { Reviews } from "./Reviews";               // static, server-data

export default function ProductPage({ params }: { params: { id: string } }) {
  const [qty, setQty] = useState(1);
  // can't await the DB here — it's a client component now, so we fetch over an API:
  const product = useProduct(params.id); // extra round-trip, loading state, more JS
  if (!product) return <Spinner />;
  return (
    <main>
      <ProductGallery images={product.images} />  {/* shipped as JS */}
      <h1>{product.name}</h1>
      <input type="number" value={qty} onChange={(e) => setQty(+e.target.value)} />
      <button onClick={() => addToCart(product.id, qty)}>Add</button>
      <Reviews productId={product.id} />          {/* shipped as JS */}
    </main>
  );
}

Everything — gallery, reviews, the whole page — is now a client bundle, and the product is fetched client-side instead of awaited on the server. The fix: keep the page on the server and extract only the stepper into a client leaf.

// AFTER — app/product/[id]/page.tsx  (Server Component again)
import { db } from "~/lib/db";
import { ProductGallery } from "./ProductGallery";
import { Reviews } from "./Reviews";
import { AddToCart } from "./AddToCart";   // the only client component

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await db.product.find(params.id); // direct, server-side, no API hop
  return (
    <main>
      <ProductGallery images={product.images} />     {/* Server, zero JS */}
      <h1>{product.name}</h1>
      <AddToCart productId={product.id} />           {/* client leaf */}
      <Reviews productId={product.id} />             {/* Server, zero JS */}
    </main>
  );
}
// AFTER — app/product/[id]/AddToCart.tsx
"use client";
import { useState } from "react";
export function AddToCart({ productId }: { productId: string }) {
  const [qty, setQty] = useState(1);
  return (
    <>
      <input type="number" value={qty} onChange={(e) => setQty(+e.target.value)} />
      <button onClick={() => addToCart(productId, qty)}>Add</button>
    </>
  );
}

Same UI. But now the gallery and reviews ship zero JS, the product is awaited on the server (no client fetch, no spinner, no waterfall), and the client bundle is one small stepper instead of the whole page.

Why this works

Why is “Server by default” the right default rather than a neutral one? Because the default is what most of a real app is: layout, text, data display, navigation — none of it interactive. Making those free (zero JS, server data access) by default and charging only for the interactive parts means the client bundle scales with interactivity, not with page size. A reader-heavy app can ship almost no JavaScript. Flip the default — client by default, server as opt-in — and you’d pay for the common case to make the rare case convenient, which is exactly backwards.

Common mistake

The signature failure mode is putting 'use client' near the top of the tree “so I don’t have to think about it” — often on a layout or a page — to satisfy one interactive child. It silences the boundary errors and the app works, which is why it survives review. But it has quietly turned the entire subtree into a client bundle and killed server data access for everything below. The tell: a Server Component error like “you’re importing a component that needs useState… mark it ‘use client’” answered by adding the directive to the importing (parent) file instead of the small leaf that actually needs state. The rule of thumb: when you hit the boundary, push the directive down to the smallest interactive component, never up.

Check yourself
Quiz

A page is a Server Component that awaits the DB. One child needs a useState toggle. The build errors: 'useState only works in a Client Component.' What's the senior fix?

Recap

In the App Router, Server Components are the default: they run on the server, ship zero JS, and can await data directly — but cannot hold state, run effects, attach event handlers, or use browser APIs. Client Components opt in with 'use client' for exactly those interactive needs, and in exchange cannot access server data or secrets directly. The directive is an entry point, not a label: everything imported below it becomes client code, so its position decides how much of your tree ships as JavaScript. The senior pattern is default to Server Components and push 'use client' down to the interactive leaves, passing server-rendered content into those leaves as children. The failure mode to recognize and reject in review is a top-of-tree 'use client' — on a page or layout — that satisfies one interactive child by turning the whole subtree into a client bundle and discarding the RSC benefit. When you hit the boundary, move the directive down, never up.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.