React Server Components: The Mental Model That Finally Clicks
Most people meet React Server Components (RSC) through a framework error message: "You're importing a component that needs useState. It only works in a Client Component, but none of its parents are marked with 'use client'." You add the magic string, the error goes away, and you never build a real model of what just happened.
That's a shame, because the underlying idea is simpler than the tooling makes it look. Server Components are not server-side rendering with extra steps, and they are not a way to fetch data faster. They are a way to run some of your component tree on the server only — code that never ships to the browser at all. Once that sentence clicks, the rest follows.
Two kinds of components, not two kinds of rendering
The first reframe: the split is per-component, not per-request. In a classic SSR app, every component runs twice — once on the server to produce HTML, then again in the browser to hydrate. Server and Client Components change the number of environments a given component ever touches.
- A Server Component runs only on the server. It can read the filesystem, query a database, and
awaitdirectly. Its code is never sent to the browser. - A Client Component runs on the server during the initial render and in the browser, exactly like components always have. It can use state, effects, and event handlers.
The official RSC documentation frames the default as "server-first": components are Server Components unless you opt them into the client. The 'use client' directive is that opt-in. It doesn't mean "render this on the client" — it means "this is the boundary where client code begins."
// app/page.jsx — a Server Component (no directive needed)
import { db } from "@/lib/db";
import LikeButton from "./LikeButton";
export default async function Page() {
const post = await db.post.findFirst(); // runs on the server, full stop
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<LikeButton postId={post.id} />
</article>
);
}// app/LikeButton.jsx — a Client Component
"use client";
import { useState } from "react";
export default function LikeButton({ postId }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked((v) => !v)}>
{liked ? "♥" : "♡"} Like
</button>
);
}The Page component awaits a database call inline. There is no useEffect, no loading spinner, no API route. That code runs once, on the server, and the browser only ever sees the resulting markup. LikeButton is interactive, so it earns its place in the bundle.
The output is a tree, not HTML
Here's the part people skip. When a Server Component renders, it doesn't produce HTML strings. It produces a serialized description of UI — sometimes called the RSC payload — that React knows how to reconcile on the client. This payload contains the rendered output of Server Components plus placeholders pointing at Client Components and their props.
That's why props passed across the boundary must be serializable. You can pass a string, a number, a plain object, even another Server Component as children. You cannot pass a function, a class instance, or a Date that round-trips cleanly — there's no way to serialize a closure across the network.
The boundary is one-directional in code, but composable in JSX. A Server Component can render a Client Component. A Client Component cannot import a Server Component — but it can accept one as children or a prop. That "pass it as a slot" pattern is how you interleave the two without dragging server code into the bundle.
"Server-first" changes where data lives
The biggest day-to-day shift is that data fetching moves into the component that needs it, on the server. The old loop — render, fire a useEffect, hit an API route, set state, re-render — collapses into a single await.
async function UserProfile({ id }) {
const user = await getUser(id); // no waterfall back to your own server
const posts = await getPosts(id);
return (
<section>
<h2>{user.name}</h2>
<PostList posts={posts} />
</section>
);
}This eliminates a whole class of client-side waterfalls and shrinks your bundle, because the data-fetching libraries, the ORM, the markdown parser, and the date formatter all stay on the server. The mental shorthand: if the code only needs to run once and produces UI, it probably belongs in a Server Component. If it needs to respond to the user — clicks, typing, focus, timers — it needs the client.
Where the boundary usually goes wrong
Three mistakes account for most of the confusion:
- Marking the whole tree
'use client'. Putting the directive at the top of your layout opts everything below it into the client and throws away the benefit. Push the boundary down to the leaves that actually need interactivity. - Trying to use hooks in a Server Component.
useState,useEffect,useContext, and event handlers likeonClicksimply don't exist there. The error is telling you to move that leaf across the boundary, not to disable the feature. - Treating it like SSR. SSR is about when HTML is produced for the first paint. RSC is about where a component's code lives and whether it ships. They're orthogonal — you can have a Server Component that never produces traditional HTML at all, and a Client Component that is server-rendered for the first paint.
If you want the canonical framing, the React docs on directives and the broader React reference are the stable sources. For the serialization rules behind props, it helps to remember the constraint comes from the structured-clone-like boundary the data has to cross.
A model you can hold in your head
Picture your component tree as a map with a coastline running through it. Everything inland is server-only: it can talk to your data, it never ships, it renders once. Everything on the coast and out to sea is client: it ships to the browser, it hydrates, it remembers state and responds to the user. 'use client' is where you draw the coastline, and you want to draw it as far out as you can — the smaller the island, the smaller the bundle.
Takeaways
- Server Components run only on the server and never ship to the browser; Client Components run in both places and own interactivity.
'use client'marks where client code begins — push it down to the interactive leaves, not the root.- Server Components emit a serializable UI description, not HTML, which is why props across the boundary must be serializable.
- Data fetching moves into Server Components via plain
await, collapsing the render-effect-fetch-setState waterfall. - RSC and SSR are orthogonal: one is about where code lives, the other about when HTML is produced.

