React Server Components (RSC) split a React app into two kinds of component that run in different places. Server Components run only on the server, at build or request time. They can be async, read a database directly, and their code never ships to the browser. Client Components are the React you already know: they hydrate in the browser and can use state, effects and event handlers. You use RSC through a framework that supports it, most commonly the Next.js App Router, where components are Server Components by default.
The idea is simple; the boundary between the two kinds is where interviews dig. What does 'use client' actually mark? What can you pass across? Why did one directive double the bundle size? The examples below follow Next.js App Router conventions and are illustrative: exact error wording varies by framework and version.
‘use client’ marks a module graph, not a component
The directive goes at the very top of a file, above the imports:
'use client';
import { useState } from 'react';
import { formatPrice } from '@/lib/format';
export function QuantityPicker({ price }) {
const [qty, setQty] = useState(1);
return (
<div>
<button onClick={() => setQty((q) => Math.max(1, q - 1))}>−</button>
<span>{qty}</span>
<button onClick={() => setQty((q) => q + 1)}>+</button>
<p>Total: {formatPrice(price * qty)}</p>
</div>
);
}When a Server Component imports this file, the bundler treats the import as a boundary. QuantityPicker becomes a Client Component, and so does every module it imports, here formatPrice, whether or not those files have the directive. That’s the part people miss: 'use client' is an entry point into the client bundle, and everything reachable from it comes along.
Two corollaries come up in interviews.
Client Components still render on the server first. In Next.js they’re server-side rendered to HTML for the initial page, then hydrated. 'use client' means “this code also ships to and runs in the browser”, not “skip SSR”. Code that touches window during render still breaks.
The directive only matters at the crossing. A component without 'use client' imported from another client module is already client code. Adding the directive to every file that uses a hook isn’t wrong, but it’s only needed at the files that Server Components import.
A worked example: a product page
Here’s a product page that keeps most of its code on the server:
// app/products/[id]/page.jsx (Server Component)
import { db } from '@/lib/db';
import { QuantityPicker } from './QuantityPicker';
import { Tabs } from './Tabs';
import { Reviews } from './Reviews';
export default async function ProductPage({ params }) {
const { id } = await params;
const product = await db.product.findUnique({ where: { id } });
return (
<main>
<h1>{product.name}</h1>
<p>{product.description}</p>
<QuantityPicker price={Number(product.price)} />
<Tabs labels={['Details', 'Reviews']}>
<section>{product.details}</section>
<Reviews productId={id} />
</Tabs>
</main>
);
}Walk through where each piece runs.
ProductPage is an async Server Component. It queries the database directly, with no API route in between, and the database client, query code and markdown or date libraries it uses never reach the browser.
QuantityPicker is a client leaf. It receives one number. The only JavaScript the page ships for it is the picker itself plus formatPrice.
Tabs is a Client Component because it tracks which tab is active, but its content comes from the server. <section> and <Reviews> are rendered on the server as part of ProductPage and passed to Tabs as children, already rendered. Tabs just decides which child to show. Reviews can itself be an async Server Component that queries the database. This is the composition pattern that makes RSC practical: a client shell with server content inside it.
Notice Number(product.price). Many ORMs return money as a Decimal class instance. Passing that object straight to QuantityPicker fails, because class instances can’t be serialised across the boundary. Converting it at the boundary is the fix.
What can cross the boundary
Props from a Server Component to a Client Component travel in the RSC payload, so they must be serialisable. Per the React docs, that includes primitives (including bigint and globally registered symbols), plain objects and arrays, Map, Set, Date, typed arrays, JSX elements, Promises, and Server Functions (functions marked 'use server'). It excludes ordinary functions, class instances other than those built-ins, and objects with a null prototype.
The classic mistake is passing an event handler from a Server Component:
// Server Component
<QuantityPicker price={price} onChange={(qty) => console.log(qty)} />This throws during rendering; in Next.js the message tells you functions can’t be passed to Client Components unless they’re Server Functions. Either define the handler inside the Client Component, or, if the work must happen on the server (saving to a cart table, say), pass a Server Function:
// app/products/[id]/actions.js
'use server';
export async function addToCart(productId, qty) {
// runs on the server; validate input and check the session here
}A Server Function is called from the client as an RPC. Treat its arguments as untrusted input, exactly like an API endpoint.
Passing a Promise is handy for streaming: a Server Component can start a slow query, pass the unresolved promise to a Client Component, and the client reads it with use(promise) inside a <Suspense> boundary.
The bundle-doubling mistake
A developer needs the quantity picker’s state and, to get it working quickly, puts 'use client' at the top of page.jsx. Now the page and everything it imports are client code. Three things happen:
- The import of
@/lib/dbpulls the database client toward the browser bundle. If it depends on Node built-ins, the build fails with a module-not-found error for something likefsornet. If it doesn’t, it may silently ship. asynccomponents and direct data access no longer work in the page, so data fetching moves into effects or an API route, with loading spinners and waterfalls.- Every library the page uses, for markdown, dates, syntax highlighting, is now downloaded and parsed by every visitor.
The fix is to push the boundary down: extract the interactive part into a small client file and keep the page a Server Component. A useful habit is to add import 'server-only' at the top of modules like lib/db.js. The server-only package makes the build fail with a clear error if any client module ever imports it, which turns this mistake from a silent regression into a build error.
Data leaks through props
Everything you pass to a Client Component is serialised into the page’s RSC payload, visible to anyone who opens the network tab, even if the component never renders that field. Passing the whole user record, <ProfileMenu user={user} />, can ship passwordHash, stripeCustomerId or internal flags to the browser. Select exactly the fields the component needs at the boundary: <ProfileMenu name={user.name} avatarUrl={user.avatarUrl} />. Treat each Client Component’s props as a public API response.
Interview exercise: where does the boundary go?
“Our blog post page renders markdown with a 90 KB parser and a syntax highlighter, shows a like button with a live count, and has a comments section that loads comments from the database and lets signed-in users post. Which components are server and which client, and where are the boundaries?”
Answer. The page itself stays a Server Component: it loads the post, runs the markdown parser and syntax highlighter on the server and outputs HTML, so neither library ships to readers. The like button is a small Client Component that receives the post ID and initial count as primitives and calls a likePost Server Function, perhaps with useOptimistic for instant feedback. The comments list is a Server Component that queries the database and renders each comment, so comment text doesn’t need client JavaScript. Only the comment form is a Client Component, receiving the post ID and calling an addComment Server Function that checks the session and validates the text on the server. If the comments should sit in a client-side collapsible panel, the panel is a client shell and the server-rendered list is passed in as children. I’d mark the database module with server-only and pass only the fields each client piece needs.