Web Development

Understanding React Server Components: A Mental Model

Server Components confuse everyone at first. The key is stop thinking about 'where code runs' and start thinking about what can be sent over the wire.

R. Okafor11 min read

React Server Components (RSC) are the most consequential change to React in a decade, and the reason they confuse people is almost entirely about framing. Most explanations start with "these run on the server" — which is true but misleading, because so do plenty of things that are not Server Components.

Here is a better mental model.

Stop asking "where does this run?"

The question "does this run on the server or the client?" is not the right axis. The right axis is: can this component's output be serialized and sent as data?

A Server Component is a component whose rendered result is pure data — a description of UI that contains no behaviour the browser needs to execute. Because it is just data, it can be produced on the server and shipped to the client as part of the page payload, without shipping its source code or its dependencies.

A Client Component is a component that does need to run in the browser — because it uses state, effects, event handlers, or browser-only APIs. Its JavaScript must be sent to the client.

The serialisation rule, concretely

A component can be a Server Component only if everything it renders is serializable. That means no functions passed as props, no class instances, no closures. Practically:

TSX
// Server Component — fine
async function ArticleList() {
  const articles = await db.article.findMany();
  return (
    <ul>
      {articles.map(a => <li key={a.id}>{a.title}</li>)}
    </ul>
  );
}
TSX
// Client Component — needs interactivity
"use client";
function SearchBox() {
  const [q, setQ] = useState("");
  return <input value={q} onChange={e => setQ(e.target.value)} />;
}

The boundary is the "use client" directive. Everything above it, in the import graph, can run on the server. Everything below ships to the client.

The part everyone gets wrong

You can pass a Server Component into a Client Component — but only as the children prop, not as a custom prop.

TSX
// Layout (server) composes client shell + server content
function Page() {
  return (
    <ClientShell>
      <ServerArticle />   {/* OK — passed as children */}
    </ClientShell>
  );
}

This works because children is rendered on the server and passed across the boundary as already-serialized data. You cannot, however, do <ClientShell article={<ServerArticle />} /> and then pass it around — custom props of component type are not part of the serializable contract in the way children is.

Why this is worth the complexity

The payoff is bundle size. A data-heavy dashboard rendered as Server Components ships essentially zero data-fetching or formatting logic to the client. The user downloads the UI description and the small interactive islands, nothing more.

For content sites — and this is the world Feature lives in — Server Components mean your article, your syntax highlighter's output, and your reading position can all be produced on the server and shipped as markup. The browser only hydrates the newsletter form and the theme toggle.

When to reach for "use client"

  • You need useState, useEffect, or useReducer.
  • You attach event handlers (onClick, onSubmit).
  • You read from the DOM or use browser-only APIs.
  • You use a library that depends on any of the above.

Everything else can stay on the server by default. Resist the urge to sprinkle "use client" — each one ships code.

Takeaways

  • The real boundary is serializability, not "where it runs."
  • Pass Server Components into Client Components via children, not custom props.
  • Default to server; reach for "use client" only when you need interactivity.

Get the mental model right and the rest of the API stops feeling arbitrary.

Related briefs