Next.js App Router: Server Components vs. Client Components
Concept
React Server Components (RSC) split a component tree into two module graphs — server and client — and Next.js's App Router builds routing, data fetching, and rendering entirely around that split. The boundary determines two separate things at once: where a component's code actually runs, and whether that code ships to the browser at all.
By default, every component under app/ is a Server Component. Its code runs only on the server (during the initial request, and again on navigation/refresh/revalidation) — it can await a database call or fetch directly in the component body, and none of that code, or its dependencies, is included in the client JavaScript bundle. A Server Component's output is serialized into a special RSC payload and streamed to the client; there's no hydration step for it, because it never runs client-side to begin with.
A component becomes a Client Component only when the file (or one of its imports) starts with a "use client" directive. That directive marks a module boundary, not just one component — everything imported from that point downward is bundled for the client too. A Client Component is still pre-rendered on the server for the initial HTML (for fast first paint and SEO), but then hydrates in the browser, attaching event handlers and becoming interactive — which is the only way to get useState, useEffect, event handlers (onClick, onChange), or browser APIs (localStorage, window), since none of those have meaning on the server.
Tradeoffs
| Approach | Benefit | Cost |
|---|---|---|
| Server Component (default) | Zero client JS shipped for that component's code and its server-only dependencies; direct await for data fetching, no client-side loading state needed; secrets/API keys never reach the browser |
No interactivity — no hooks, no event handlers, no browser APIs; re-renders only on server-driven events (navigation, refresh, revalidation), not on client state changes |
Client Component ("use client") |
Full interactivity — hooks, event handlers, browser APIs, real-time client-side state | Its code, and everything it imports, ships to the browser as JS; requires hydration (a real, measurable cost on slow devices); can't await server-only resources directly |
Over-marking "use client" (whole tree) |
Feels familiar if coming from pre-RSC React — "just add the directive and move on" | Silently drags every child import into the client bundle too, erasing most of the App Router's actual benefit — this is the most common way teams end up with a Next.js 15 app that ships nearly as much JS as a pre-RSC SPA |
The tradeoff isn't really "Server vs. Client" as competing implementations of the same thing — they're complementary, and the real design work is where to draw the boundary, since "use client" pulls the entire subtree below it into the client bundle.
When to use / when not to
- Default to Server Components for anything that doesn't need interactivity: layouts, data-fetching wrappers, static content, anything that only reads props and renders — this is the official Next.js guidance and it's not just a style preference, it directly controls bundle size.
- Reach for Client Components only at the specific leaf where interactivity is actually needed — a form input, a dropdown toggle, a component using
useState/useEffect— and push the"use client"boundary as deep into the tree as possible rather than marking a whole page or layout. - A common, effective pattern: keep a page's data-fetching shell as a Server Component and pass server-fetched data down as props into a small Client Component that only handles the interactive slice (e.g. a filter dropdown that re-renders a list client-side after an initial server-rendered load).
- Don't default to
'use client'"just in case" on a new component — the App Router's entire performance case rests on most of the tree staying server-only; reflexively adding the directive because it worked in the old Pages Router (or in plain React) reintroduces the bundle-size problem RSC exists to solve.
Common pitfall
Marking a shared layout or a high-level page component "use client" because one small piece inside it needs interactivity, without realizing the directive pulls every import beneath that point — including data-fetching logic and heavy dependencies — into the client bundle. The fix is composition, not avoidance: keep the outer component a Server Component, and pass the interactive piece down as a child Client Component (Server Components can render Client Components as children; the reverse — importing a Server Component directly into a Client Component — is not allowed, since a Client Component can't await server-only code).
Engineering Lens
The Server/Client split turns a decision that used to be implicit ("this is a React app, everything runs on the client") into an explicit, file-level choice that a code reviewer can actually see and question. The strong review answer isn't "does this component work" — it's whether the "use client" boundary sits at the smallest subtree that genuinely needs it, since every level it moves up the tree is client bundle size and hydration cost paid by every user on every page load, whether or not they ever touch the interactive part. This is the same underlying discipline as minimizing blast radius elsewhere in a system — the question is always "how much of this needs to be exposed/shipped/loaded," just applied to a component tree instead of a network boundary.
Sources
- Next.js official docs — Getting Started: Server and Client Components
- Next.js official docs — Guides: Server and Client Boundary