The mental model that made Server Components click
Most confusion about RSC comes from thinking of it as rendering. It's closer to serialisation — and once you see it that way, the rules stop feeling arbitrary.
- react
- next.js
- architecture
Every team I've watched adopt React Server Components hits the same wall in the same week. Someone tries to pass a function as a prop across the boundary, gets an error about it not being serialisable, and asks why React is being difficult about it.
The answer is that the boundary isn't a rendering boundary. It's a network boundary, and it has been the whole time.
Two programs, not one
A server component and a client component are not two kinds of the same thing. They run in different processes, at different times, with different capabilities. The server component runs once, during the request, on a machine with your database credentials. The client component runs in a browser, possibly many times, on a device you have never met.
What passes between them has to survive being written to a stream and read back. That is the entire rule. Everything that looks like an arbitrary restriction follows from it:
- A function can't cross the boundary, because you cannot serialise a closure and its captured scope.
- A class instance arrives as a plain object, because its prototype didn't come along.
useStatedoesn't exist on the server, because there is no second render to hold state for.
None of this is React being opinionated. It's what happens when you accept that one side of the tree is generated somewhere else.
The question that resolves most design arguments
When a team argues about whether something should be a server or client component, the productive question is not "does it need interactivity". It's:
What does this component need that only exists in one place?
A component that needs the database belongs on the server. A component that needs
onClick, window, or state belongs on the client. A component that needs both is
usually two components that haven't been separated yet.
That last case is where most of the real work is. The instinct is to mark the whole
subtree "use client" and move on, which works and quietly ships the data-fetching
logic to the browser along with it. The better move is almost always to keep the
data on the server and pass the result down:
// Server: has credentials, runs once, ships no JS.
export default async function DashboardPage() {
const metrics = await getMetrics();
// The chart needs canvas and pointer events, so it's a client island —
// but it receives finished data, not the ability to fetch it.
return <MetricsChart data={metrics} />;
}The chart is still interactive. The query never left the server. The browser downloaded a chart library, not a database client.
Where the model strains
Two places, honestly.
Context. Provider-based patterns assume one continuous tree, and the boundary breaks that assumption. A server component cannot read a client context. Usually the fix is to hoist the provider high in the client portion and accept that anything below it is client-side — which is a real cost, and worth noticing before you adopt a library that depends on it.
Third-party components. Plenty of libraries have not marked their client components, and you find out at build time. This is improving, but it is the most common source of "why doesn't this work" that has nothing to do with your code.
The part worth internalising
Server Components are not a performance feature you turn on. They're a statement about where code runs, and they make that statement checkable. The performance win — shipping less JavaScript — is a consequence of having been forced to be explicit about which half of your application actually needed to be in the browser.
Most applications, it turns out, had been shipping far more of themselves than they needed to. RSC didn't make that true. It just made it visible.