Frontend · Next.js
React Server Components vs. Client Components
Server Components ship zero JS by default and Client Components buy interactivity at a cost — the App Router forces you to choose deliberately, per component.
Before the App Router, every React component you wrote was implicitly a client component. It rendered once on the server for the initial HTML, then React re-rendered the exact same component tree in the browser, wired up event handlers, and kept it alive for the lifetime of the page. That model is simple, but it means every component — even a static footer that never changes — ships its code to the browser and pays the cost of hydration.
The App Router inverts the default. Every component is now a Server Component unless you explicitly say otherwise, and that one change reshapes how you think about building a page.
What actually changed
A Server Component renders exclusively on the server — during the request, or at build time for static routes. It produces HTML (and a compact serialized description of the tree) that gets sent to the browser. No JavaScript for that component is included in the client bundle at all, because there's nothing for the browser to do with it: it already has the rendered output. If your app is mostly layout, data fetching, and text — which most apps are — the App Router lets almost all of it ship zero client-side JS by default.
This is a fundamentally different trade-off than lazy-loading or code splitting. Those techniques defer when JS arrives; Server Components avoid sending it in the first place, for the lifetime of that component.
The "use client" directive is a boundary, not a label
It's tempting to read "use client" as a tag you slap on a file to say "this component uses hooks." That's not what it does. It marks the boundary where the module graph crosses from server-only to client-bundled. Everything that file imports — every component it renders directly, every helper it calls — gets pulled into the client bundle with it, because the bundler now has to assume all of it might run in the browser.
Boundary, not isolation
Put "use client" at the top of a component and every non-Server-Component descendant it imports directly becomes part of that same client bundle. It doesn't quarantine just that one file — it draws a line in the tree, and everything on the client side of that line ships to the browser.
That's why the directive belongs as low in the tree as possible: a single misplaced "use client" near the root can drag your entire page — and every module it imports — across the boundary, quietly undoing the zero-JS default for everything beneath it.
What only Server Components can do
Because a Server Component never runs in the browser, it can do things a Client Component fundamentally can't:
- Talk directly to your backend. Query a database, read from the filesystem, or call an internal service with
awaitright inside the component — no API route required as a middleman. - Keep secrets off the client entirely. An API key or database credential referenced in a Server Component never appears in any bundle the browser downloads, because the component never gets compiled into one.
- Skip heavy dependencies in the bundle. A large Markdown renderer, PDF parser, or date-formatting library used only on the server costs the client nothing — it's never shipped.
- Ship zero JavaScript for that component's own code, full stop.
What only Client Components can do
The moment a component needs to react to something happening in the browser, it needs to actually run in the browser — which means it needs "use client":
useState,useReducer, and any hook that holds state across re-renders in response to user input.useEffectand other lifecycle hooks that run after the browser has painted.- Event handlers —
onClick,onChange,onSubmit, and everything else that responds to a user action. - Browser-only APIs —
window,localStorage, geolocation, or any third-party library that assumes a DOM.
The composition rule that trips people up
Server Components can render Client Components freely — that's the normal, expected direction, and it's exactly what lets a server-rendered page include an interactive button or form. But the reverse doesn't work: a Client Component cannot import and render a Server Component directly.
The reason is structural, not arbitrary. Once a module is pulled across the "use client" boundary, everything it imports has to be compiled into the client bundle too — and a Server Component's code might contain a direct database call or a server-only secret. There's no safe way to put that in a bundle the browser downloads and executes. So the bundler simply won't let a client module import one.
What a Client Component can do is receive an already-rendered Server Component as a prop or as children. The server renders it first, produces its output, and hands that finished result down — the client never sees the Server Component's source, only its output slotted into the tree. This is the pattern behind passing server-fetched content into a client-side layout, modal, or tab container.
A small example
Here's the shape most real pages end up taking: a Server Component does the data fetching and passes only the slice of data a small interactive piece actually needs down to a Client Component.
// app/dashboard/page.tsx — Server Component (no directive needed)
async function DashboardPage() {
// Runs on the server only: direct DB access, no client bundle cost.
const user = await db.user.findUnique({ where: { id: getSessionUserId() } });
return (
<div>
<h1>Welcome back, {user.name}</h1>
{/* Only a slice of server data crosses the boundary as a prop */}
<FavoriteToggle userId={user.id} isFavorited={user.isFavorited} />
</div>
);
}
// components/favorite-toggle.tsx — Client Component
("use client");
function FavoriteToggle({ userId, isFavorited }) {
const [favorited, setFavorited] = useState(isFavorited);
return (
<button
onClick={async () => {
setFavorited(!favorited);
await fetch(`/api/favorite/${userId}`, { method: "POST" });
}}
>
{favorited ? "★ Favorited" : "☆ Favorite"}
</button>
);
}DashboardPage never ships to the browser — its database call, its imports, none of it. FavoriteToggle is the only piece that does, and it receives just userId and isFavorited as plain serializable props, not the whole user record and not a live connection to anything server-side.
Props crossing the boundary must be serializable
Anything passed from a Server Component into a Client Component has to survive serialization — plain objects, arrays, strings, numbers, and booleans work; functions, class instances, and database connections don't. If you need a Client Component to trigger server logic, pass a Server Action instead of trying to hand it a server-side function reference.
The practical mental model
Stop thinking about "is this page a Server Component or a Client Component" and start thinking per-component. Default everything to a Server Component. When something genuinely needs state, effects, or event handlers, carve out the smallest possible piece — a single button, a single form field, a single dropdown — mark just that leaf with "use client", and let the Server Component above it keep doing the data fetching and pass down only what that leaf needs.
Pushed far enough, this turns most pages into a mostly-static shell rendered entirely on the server, with a handful of small interactive islands scattered through it — each one paying for its own bundle size, and nothing else.
Key takeaways
- Every component in the App Router is a Server Component by default and ships zero client JS unless it opts in.
- "use client" marks a boundary in the module graph, not a label on one file — everything it imports crosses with it.
- Only Server Components get direct backend access, kept secrets, and heavy server-only dependencies for free.
- Only Client Components get useState, useEffect, event handlers, and browser APIs — and paying for that requires the directive.
- Client Components can't import Server Components directly; they can only receive already-rendered server output as children or props.
- Push "use client" down to the smallest interactive leaf and default everything else to Server Components.
