React Server Components: The Mental Model That Finally Clicks

Photo by Ilham Malik

Photo by Ilham Malik
I have onboarded several developers onto App Router projects over the past two years, and every single one of them hit the same wall: React Server Components feel arbitrary until the mental model clicks. They sprinkle use client on whatever errors out, watch the bundle balloon, and conclude RSC is Next.js being difficult. The model is actually simple — it is just genuinely different from everything React taught us for a decade.
This post is the explanation I now give in onboarding, refined by repetition: what runs where and when, why the boundary lives in the module graph and not the component tree, what survives the serialization wall between server and client, and a four-step decision rule for the use client directive that ends the guesswork.
The most common confusion is collapsing Server Components into server-side rendering. They are orthogonal. SSR takes a client application and pre-runs it on the server to produce initial HTML — then ships all the component code anyway, because the client must hydrate and take over. Every component still pays its bundle cost. Client Components in the App Router still work exactly like this: they render on the server for the initial HTML and again in the browser.
Server Components are a different species. Their code never leaves the server. The browser receives their rendered output — a serialized description of the UI — but never the function that produced it, nor its imports. A markdown parser, a syntax highlighter, a database client imported by a Server Component adds exactly zero bytes to the client bundle. And because the code never reaches the browser, Server Components never re-render. They run once per request, or once at build time, and they are done. No hydration, no dependency arrays, no stale closures, no memoization rituals.
Here is the full picture in one table. The third row is the one people forget exists:
| Component kind | Executes | In client bundle? | Re-renders? |
|---|---|---|---|
| Server Component (default) | Server only — at build time for static routes, at request time for dynamic ones | Never | Never; a new request produces a new render |
| Client Component (use client) | Server for initial HTML, then browser after hydration | Yes, plus everything it imports | Yes — normal React state and effects |
| Shared component (no directive, no server-only APIs) | Depends on who imports it | Only if a client module imports it | Only when rendered as a Client Component |
That third row is a subtlety straight from the React docs: a component is not intrinsically a server or client component. The same Button file, with no directive, renders as a Server Component when a page imports it and as a Client Component when something under a use client boundary imports it. The directive marks a boundary, not an identity.
This is the insight that untangles most confusion. When you put use client at the top of a file, you are not labeling one component — you are declaring that this module and every module it imports, transitively, belongs to the client bundle. The boundary is drawn through import statements, not through JSX nesting.
// The boundary lives in the MODULE graph, not the render tree.
// sidebar.tsx — Client Component (and so is everything it imports)
"use client"
import { HeavyChartLib } from "heavy-chart-lib" // now in the client bundle too
// page.tsx — Server Component (default, no directive)
import { Sidebar } from "./sidebar"
import { db } from "@/lib/db" // never reaches the browser
export default async function Page() {
const stats = await db.stats.findMany() // direct DB access, no API layer
return <Sidebar stats={stats} /> // stats must be serializable
}
// The escape hatch: pass Server Components THROUGH a client
// boundary as children. The client shell re-renders;
// the server-rendered children do not.
// layout.tsx (server)
import { ThemeProvider } from "./theme-provider" // "use client"
export default function Layout({ children }) {
return <ThemeProvider>{children}</ThemeProvider>
// children stay server-rendered
}The children pattern in that snippet is the most practical consequence. A use client provider does not drag its JSX children to the client, because the layout — a Server Component — already rendered them and passes the finished output through as a prop. This is how an entire app can sit inside a theme provider, an auth context, and an analytics wrapper while the pages themselves stay server-only. When my navbar needed a scroll listener on this portfolio, I did not convert the layout; I carved out a twenty-line ScrollHeader client component and passed everything else through as children.
Props that cross from a Server Component into a Client Component travel over the network as part of the RSC payload, so they must survive serialization. React enforces a strict list, and knowing it by heart prevents an entire category of runtime errors:
Crosses the boundary
Blocked at the boundary
The class-instance restriction bites hardest in real projects. A Prisma Decimal or a Dayjs date will throw the moment it touches a client prop. The fix is a mapping step at the boundary — convert to plain numbers and ISO strings in the Server Component. Security people will tell you this restriction is a feature: it forces you to decide field by field what data actually leaves the server, instead of leaking whole ORM entities into the HTML.
My rule of thumb, applied top to bottom:
The directive is viral through imports. One careless use client at the top of a shared barrel file can silently pull half your component library — and its dependencies — into the client bundle. Audit what your client modules import, not just where you wrote the directive.
Two worlds, one tree. Server Components run once, on the server, with direct access to data, and ship nothing; Client Components hydrate and live in the browser with state and events. The use client directive draws the frontier through the module graph, everything crossing it must serialize, and JSX passed as children is the diplomatic pouch that lets server-rendered content travel through client territory untouched.
Once that paragraph is intuition rather than trivia, the App Router stops feeling like a minefield. You stop sprinkling directives to silence errors and start placing boundaries deliberately — and your bundle analyzer will show you the difference in kilobytes.
Run a bundle analysis before and after moving a use client boundary down one level. The first time you watch forty kilobytes of charting library disappear from your shared chunk, the module-graph model stops being abstract.