
Most confusion about React Server Components comes from reaching for the wrong analogy. "It is like SSR" explains the first render and nothing else. "It is like PHP" explains the jokes and nothing else. After migrating a production surface at work and rebuilding this site around RSC, the model that finally made every rule feel obvious is this:
A Server Component tree is a serialized UI description streamed to the client. "use client" marks the islands where interactivity resumes.
Everything else — the rules, the errors, the weird prop restrictions — falls out of that sentence.
The tree crosses the wire, not just HTML
SSR sends you HTML and then re-runs your entire app in the browser to make it interactive. RSC sends something different: a serialized description of the component tree (the "RSC payload") that React on the client merges with the Client Components it already has. Server Components never ship to the browser at all — their code, their dependencies, their secrets stay on the server.
// This entire module never reaches the browser bundle.
import { db } from "@/lib/db"
export async function ProjectList() {
const projects = await db.query("select * from projects")
return (
<ul>
{projects.map((p) => (
<ProjectCard key={p.id} project={p} />
))}
</ul>
)
}No API route. No useEffect fetch. No loading state for data the server already had. The database call is the render.
The boundary is a serialization boundary
The rule that bites everyone: props passed from a Server Component into a Client Component must be serializable. Once you know why, it stops being arbitrary — those props are embedded in the RSC payload, which is a wire format. Wire formats do not carry class instances, closures, or Maps.
// Server Component
<Chart from={new Date()} /> // arrives as a string on the client
// Fix: serialize explicitly, type honestly
<Chart from={new Date().toISOString()} />Treat the boundary like an API contract and design props the way you would design a REST payload. I wrote a shorter note on exactly this trap.
Where the wins actually come from
Three places, in order of how much they mattered in production:
- Bundle size. Server Components and their dependencies vanish from the client bundle. Our tracking surface dropped 58% of initial JS, mostly by letting the server keep the date library, the markdown renderer, and the formatting code.
- Data access without a round trip. Async components collapse the fetch-render-waterfall into server-side latency, which is usually a private network hop instead of a public internet one.
- Composition. A Server Component can render around a Client Component and pass it server-rendered children. Interactive islands inside static oceans, without prop-drilling the ocean through the island.
Where it still hurts
Honesty section. The cache()/revalidateTag mental model takes real time to internalize, and stale-data bugs during the learning curve are subtle. Error boundaries across the boundary behave differently enough to surprise you once. And the tooling story for seeing what is in your RSC payload is still immature — you will add logging you did not expect to need.
The one-sentence version
Server Components move the data-dependent part of your UI next to the data and ship the result as a serialized tree; Client Components are the interactive islands embedded in it. Design the islands small, the contracts explicit, and the oceans server-rendered.