React Server Components: the boundary, the payload, and what crosses it
RSC run only on the server and ship zero JS — the client receives a serialized tree (the RSC payload), not a bundle. "use client" marks a boundary, not a component; only serializable props cross it. Hooks are impossible server-side: the component has no client lifetime.
The migration to the App Router was supposed to shrink the bundle. Instead, a month in, the analytics dashboard’s client JS had grown by 180KB. The culprit took a day to find: someone needed an onClick on a table row, got the familiar error — “Event handlers cannot be passed to Client Component props” — and “fixed” it the fast way: slapped "use client" on the top of DashboardLayout.tsx. That one directive didn’t mark one component as client-side. It marked a boundary — and everything DashboardLayout imported, the entire subtree of charts, formatters, and the 90KB table library, got pulled into the client bundle with it. Worse: a date-fns locale bundle and a half-initialized analytics SDK came along via transitive imports. The team had read "use client" as a per-component annotation. It is not. It is a door in the module graph, and everything imported behind that door ships to the browser.
By the end of this lesson you’ll know exactly where to draw the "use client" line — and how to audit an existing codebase to find the boundaries bleeding into the bundle.
Components that never ship
Why does any of this matter? Every KB your client downloads must be parsed and executed before the page is interactive — and most of a typical Next.js codebase is rendering logic that never needs to run in the browser at all. RSC lets you draw that line explicitly and pay nothing for crossing it.
A React Server Component is a component that executes only on the server — at build time or request time — and whose code is never included in the client bundle. That second clause is the entire economic argument. A classic SSR component renders on the server and then again on the client during hydration, so its code ships twice-used but once-downloaded; an RSC renders once, server-side, and the browser receives only its output. The component can therefore do things browser code never could: query the database directly, read the filesystem, use a heavyweight markdown renderer or syntax highlighter, hold API secrets — none of it leaves the server.
// app/orders/page.tsx — a Server Component (the default in app/)
import { db } from "~/lib/db"; // server-only: never bundled for the client
import { formatMoney } from "~/lib/money";
export default async function OrdersPage() {
// Data fetching co-located with the component that needs it.
// No useEffect, no loading-state dance, no API route in between.
const orders = await db.order.findMany({ take: 50, orderBy: { createdAt: "desc" } });
return (
<table>
<tbody>
{orders.map((o) => (
<tr key={o.id}>
<td>{o.customerEmail}</td>
<td>{formatMoney(o.totalCents)}</td>
</tr>
))}
</tbody>
</table>
);
}Note what’s absent: no useEffect + fetch + loading spinner, no API endpoint built solely to ferry data to the browser. The component is async, it awaits its own data, and the data layer is co-located with the markup that consumes it. This kills the classic client-fetching waterfall where the browser downloads JS, runs it, then discovers it needs data, then makes another round trip — on the server, component and database are often in the same datacenter, and that round trip is ~1ms instead of ~100ms.
What the client actually receives is the RSC payload: a compact serialized description of the rendered tree — the HTML-shaped output of server components, plus placeholders (“render the client component from chunk X here, with these props”) wherever a client component appears. React on the client consumes this payload to construct the tree without re-running any server code. This is also what makes soft navigation work: clicking a link fetches the new route’s RSC payload, and React merges it into the existing tree — client components in the persistent layout keep their state (your half-typed search box survives navigation), because the payload is a description to reconcile, not an HTML page to replace.
What does the browser receive for a Server Component that renders a 50-row table using a 90KB markdown library?
”use client” is a boundary, not a marker
The most common senior-level misreading of RSC: treating "use client" as “this component is a client component.” What the directive actually declares is a boundary in the module dependency graph: this file, and everything reachable through its imports, belongs to the client bundle. You don’t annotate every interactive component — you annotate the entry points where the server tree hands off to client territory, and the bundler walks the import graph from there. That’s why the Hook’s one-line “fix” pulled 180KB across: marking a layout file marks its entire import subtree.
Two consequences follow. First, the practical pattern is push client boundaries to the leaves: keep layouts, pages, and data-heavy components as server components, and carve out the smallest interactive islands — <AddToCartButton>, not <ProductPage>. Second, a client component cannot import a server component (importing would drag server code into the client graph — the build will treat that import as a client component, or fail on server-only APIs). But a client component can receive server-rendered content as children or any other prop: the server renders the inner tree, and the client component slots the already-rendered result into place. Interleaving is done by composition, not by import.
// theme-provider.tsx
"use client";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light");
return <ThemeCtx.Provider value={{ theme, setTheme }}>{children}</ThemeCtx.Provider>;
}
// app/layout.tsx — Server Component
export default function RootLayout() {
return (
<ThemeProvider>
{/* Still server-rendered, zero client JS — passed THROUGH the
client component as children, not imported by it. */}
<HeavyServerSidebar />
</ThemeProvider>
);
}What may cross: serializable props only
When you define a prop on a server component and pass it down to a "use client" component, ask yourself: could this value survive being written to JSON, sent over the wire, and revived in a completely separate process hours later? If the answer is no, the prop cannot cross.
Props flowing from a server component into a client component cross a real process-and-time boundary — they are serialized into the RSC payload on the server and revived in the browser. So they must be serializable by React’s RSC serializer: JSON-style primitives, plain objects and arrays, Date, Map/Set, FormData, typed arrays, JSX, and promises (which stream in as they resolve). What cannot cross: functions (an onClick closure has captured server scope — there is no way to revive it in a browser), class instances (a Prisma model object will throw or silently lose its prototype), and anything carrying live resources like sockets or DB handles. This is why “Functions cannot be passed directly to Client Component props” is a boundary error, not a syntax error — and why the two legitimate escape hatches are shaped the way they are: pass data down and let the client component own the handler, or pass a Server Action (a function marked with "use server"), which is serializable precisely because what crosses the wire is a reference — an ID the client can invoke via a network call — never the closure itself.
A dashboard page has a persistent sidebar that queries the database plus a ThemeToggle button that stores preference in localStorage. Where should the 'use client' boundary be placed to minimise the client bundle?
The same boundary explains why hooks and state are impossible in server components. useState presumes an instance that survives re-renders and receives events over time; a server component executes once per render request and is gone — there is no second render for state to persist across, no event loop listening for clicks, no lifetime. useEffect presumes a DOM to affect; there isn’t one. The rule “no hooks in server components” isn’t a framework limitation to be patched later — it falls directly out of what a server component is: a function of data to output, executed in an environment with no interactive lifetime.
▸Why this works
Why co-locate data fetching instead of centralizing it in loaders or API routes? Because the component is the unit that knows what it needs. With centralized loading, every change to a component’s data needs ripples upward through loader signatures and endpoint shapes — and over-fetching creeps in because the loader serves the union of all consumers’ needs. With RSC, the component awaits exactly its own query; React deduplicates identical fetches within a render pass, and parallel siblings fetch concurrently. The waterfall risk inverts: the anti-pattern to watch for is sequential awaits in a parent blocking children that could have fetched in parallel — solved by starting promises early and passing them down, or by Promise.all, not by reintroducing a central loader.
A server component passes props to a client component. Which prop will break the build or runtime serialization?
- 01Explain why 'use client' is called a boundary rather than a marker, and what the practical placement rule is.
- 02What can cross the server→client prop boundary, what cannot, and why can't server components use hooks?
A Server Component executes only on the server and its code never enters the client bundle — the browser receives the RSC payload, a serialized description of the rendered tree with placeholders pointing at client-component chunks and their props. That payload is what React reconciles on soft navigation, which is why client state in persistent layouts survives route changes. Because the code never ships, server components may do what browser code cannot: query the database directly, hold secrets, use heavyweight libraries at zero client cost — and data fetching co-locates with the component as a simple await, deleting the download-JS-then-fetch waterfall and the API routes that existed only to ferry data. The “use client” directive is a boundary in the module dependency graph, not a per-component marker: the file and its entire import subtree ship to the client, so boundaries belong at the leaves — the smallest interactive islands, not layouts. A client component cannot import a server component, but server-rendered JSX passes through it as children: interleave by composition. Across the boundary, props must survive serialization: primitives, plain objects, Date, Map, Set, FormData, JSX, and promises cross; functions, class instances, and live resources do not — closures cannot be revived in another process. Server Actions cross because only an invocable reference is serialized. And hooks are impossible server-side by definition: a server component has no client lifetime — no re-renders for state to persist across, no event loop, no DOM — it is a pure function from data to output that runs once and is gone. Now when you see a bundle-size regression after a “quick fix,” the first thing to check is whether a "use client" boundary moved — and how much of the import graph moved with it.
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.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.