Server Components vs Client Components: The "use client" Boundary
React Server Components run on the server and ship zero JS; the "use client" directive marks a one-directional boundary that dictates where state, data fetching, and serialization live.
React Server Components (RSC) split your component tree into two execution environments — the server, where components render once and ship zero JavaScript, and the client, where interactivity lives behind a "use client" directive. The hard part is not the syntax; it is internalizing that the boundary between them is one-directional and that everything crossing it must be serializable.
1. The two-environment mental model
A Server Component renders on the server (at request time, or at build time for static routes). It can be an async function, await a database query or fetch, and read secrets — and its output is a serialized description of UI, not JavaScript. The component's code never reaches the browser bundle. The price: no hooks, no state, no effects, no event handlers, and no access to browser APIs like window or localStorage, because none of that exists where it runs.
// app/page.tsx — a Server Component by default (no directive)
import { db } from "@/lib/db";
export default async function Page() {
const posts = await db.post.findMany(); // runs on the server only
return (
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
A Client Component opts in with "use client" at the top of the file. It is the React you already know: it hydrates in the browser, ships its JS, and can use useState, useEffect, refs, and event handlers. The directive does not mean "render only on the client" — Client Components still server-render to HTML for the initial paint, then hydrate. It means "this module and its imports are part of the client bundle, and this is where interactivity begins."
"use client";
import { useState } from "react";
export function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}
The interview phrasing: Server Components are the default and render once with zero client JS; Client Components are an opt-in island for state and interactivity.
2. The boundary is one-directional
This is the rule that trips up most engineers. "use client" marks an entry point, not a single component. Once you cross it, every module imported down that tree is also client code. A Client Component therefore cannot import a Server Component — if it did, the imported module would be treated as client code, so its server-only work (database access, secrets, heavy dependencies) would either be pulled into the client bundle or fail to bundle at all, which defeats the entire model.
"use client";
// This forces ServerThing to be bundled as client code.
// If it touches the DB or fs, the bundle breaks or leaks.
import { ServerThing } from "./server-thing";
export function Panel() {
return <ServerThing />;
}
But the inverse works, and it is the key pattern: a Client Component can receive a Server Component as children or props. The trick is where composition happens. The Server Component is rendered by a server parent and passed down as an already-rendered element; the Client Component just slots it into place without importing it.
// Server Component (parent) — composes the tree
import { ClientLayout } from "./client-layout";
import { ServerStats } from "./server-stats";
export default function Page() {
return (
<ClientLayout>
{/* rendered on the server, handed to the client as a slot */}
<ServerStats />
</ClientLayout>
);
}
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
export function ClientLayout({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(true);
return (
<div>
<button onClick={() => setOpen((o) => !o)}>toggle</button>
{open && children} {/* the Server Component's output lives here */}
</div>
);
}
The mental model: children is an opaque, already-rendered React node from the client's perspective. The client never executes the Server Component, so its server-only code stays on the server. This is how you keep an interactive shell (tabs, accordions, modals) while keeping the expensive, data-heavy content as zero-JS Server Components.
3. Serialization across the boundary
When a Server Component renders a Client Component, the props it passes are serialized into the RSC payload and sent to the browser. That payload is a streaming format — not literal JSON, but with a similar constraint: values must be serializable by React. The supported set is broader than JSON. You can pass string, number, bigint, boolean, undefined, null, arrays, Map, Set, TypedArray and ArrayBuffer, Date, plain objects with serializable properties, promises, JSX/React elements, and globally-registered symbols (those created with Symbol.for). What you cannot pass are values with behavior or non-portable identity: ordinary functions, class instances (anything that is an instance of a class other than the supported built-ins), objects with a null prototype, and symbols that are not globally registered (e.g. Symbol("x")).
// Server Component
export default async function Page() {
const user = await getUser();
return (
<Profile
name={user.name} // string — ok
joinedAt={user.joinedAt} // Date — supported
onSave={() => save()} // plain function — throws at the boundary
/>
);
}
The function case is the one to remember. Event handlers must originate inside a Client Component, not be passed in from the server. The exception — and a deliberate one — is Server Functions declared with the "use server" directive (commonly used as Server Actions): those are passable across the boundary because React replaces them with a reference (an ID) that the client invokes via an RPC back to the server. A plain client-side closure has no such reference, so it cannot cross. So "no functions" is really "no functions except Server Functions."
4. Where data fetching belongs
Push data fetching up and onto the server. A Server Component can await directly in the render body, which collapses the entire "fetch in useEffect, set state, render a spinner" dance into a single async function. There is no client-side request waterfall for that data, no loading state to manage, and the query credentials never leave the server.
// Server Component — fetch where you render
export default async function Dashboard() {
const [stats, activity] = await Promise.all([
getStats(), // parallel — no waterfall
getActivity(),
]);
return (
<>
<Stats data={stats} />
<Activity data={activity} />
</>
);
}
Client-side fetching still has its place: data that depends on user interaction, polling, optimistic updates, or anything keyed off browser state. For that, fetch in a Client Component (often via a library like React Query) or call a Server Function. The decision rule: if the data is needed to render the initial view and does not depend on the client, fetch it in a Server Component. Reserve client fetching for what genuinely happens after hydration.
You can also stream: wrap a slow Server Component in <Suspense> and React sends the shell immediately, then streams the resolved subtree when its promise settles — no client coordination required.
import { Suspense } from "react";
export default function Page() {
return (
<Suspense fallback={<Skeleton />}>
<SlowServerComponent /> {/* awaits internally, streams in */}
</Suspense>
);
}
5. Common mistakes
Putting "use client" too high. The most expensive mistake. If you drop the directive at the top of a layout or page, the entire subtree defined in that module tree becomes client code — all of it ships JS, none of it can be async on the server, and you have effectively turned off RSC for that branch. Keep the directive at the leaves: the actual interactive widget, not its container. A static article with one "like" button should have the button as a Client Component, not the whole article. (Note that a Client Component can still receive Server Components through children, so a high-up interactive shell does not have to make its slotted content client code.)
Passing plain functions across the boundary. A Server Component cannot hand an ordinary callback to a Client Component. If you need behavior, either define it inside the Client Component, or use a Server Function when the behavior must run on the server. Trying to pass a plain function throws a serialization error.
Importing a Server Component into a Client Component. Use the children/props slot pattern from section 2 instead of a direct import. The bundler will otherwise treat the imported module as client code.
Reaching for browser APIs in a Server Component. window, document, and localStorage do not exist on the server. Move that logic into a Client Component, and confine it to an effect: useEffect runs only in the browser, so reading window inside it is safe and avoids the server-render pass.
Assuming "use client" means client-only. Client Components are still server-rendered for the initial HTML and then hydrated. Code in the component body runs on both the server (during SSR) and the client (during hydration); only effects and event handlers are client-exclusive. Forgetting this leads to hydration mismatches when the body reads non-deterministic values like Date.now(), Math.random(), or window. Move such reads into useEffect, or gate them behind a mounted flag.
Takeaways
- Default to Server Components. Add
"use client"only at the interactive leaf, as low in the tree as possible. - The boundary is one-way: a Client Component can receive a Server Component via
children/props, but never import one. - Everything crossing the boundary must be serializable by React — pass data, not behavior. The supported set is broader than JSON (Dates, Maps, Sets, typed arrays, promises, JSX,
bigint, global symbols); the notable exclusions are plain functions, class instances, and non-global symbols. Server Functions are the one function-shaped exception, crossing as references. - Fetch initial, client-independent data in Server Components with
await; reserve client fetching for interaction-driven or live data. - Remember Client Components still server-render — keep their render body deterministic and push browser-API and non-deterministic reads into effects.