Understanding React Server Components
A mental model for RSC: what actually runs where, what the RSC payload is, and a practical rule for when to reach for 'use client'.

On this page
React Server Components confused me for a long time because most explanations start with the rules ("you can't use hooks!") instead of the model. Once the model clicks, the rules become obvious. Here's the version that finally worked for me.
The mental model: two programs, one tree
Your component tree is now split across two programs:
- The server program runs at build time (for static pages) or request time. Its components can read the filesystem, query databases, and import heavy libraries — none of that code ships to the browser.
- The client program runs in the browser. Its components can hold state, attach event handlers, and use effects.
The output of the server program is not HTML — it's a serialized description of the UI called the RSC payload. The client program receives that payload and fills in the interactive holes.
Seeing the payload with your own eyes
View source on any App Router page and look for self.__next_f.push script tags. That's the RSC payload, inlined for hydration. It's also why a grep for your content against a production page often returns double the count you expect — once in the HTML, once in the payload. Not a bug.
Server by default is the whole point
A component is a Server Component unless something forces it to be otherwise. This default is what makes the model pay off:
import fs from "node:fs";
import matter from "gray-matter";
// This runs at build time. fs and gray-matter never reach the browser.
export function PostList() {
const posts = fs
.readdirSync("content/blog")
.map((file) => matter.read(`content/blog/${file}`));
return (
<ul>
{posts.map((post) => (
<li key={post.data.title}>{post.data.title}</li>
))}
</ul>
);
}No useEffect, no loading spinner, no API route. The data is just there, because the component ran where the data lives.
When to reach for "use client"
My rule: push "use client" to the leaves. A page should be a server component composing server components, with small client islands exactly where interactivity lives.
Signs a component must be a client component
- It calls
useState,useReducer, oruseEffect - It attaches event handlers (
onClick,onChange) - It uses browser-only APIs (
window,IntersectionObserver,localStorage)
Signs it should stay a server component
- It only receives props and renders markup
- It fetches or reads data
- It imports something heavy you don't want in the bundle
A subtle one: a server component can render client components and pass them server-rendered children. The boundary is about imports, not the render tree. This means layout chrome can stay on the server even when it wraps interactive widgets.
The payoff
On this site, the blog index you're reading was generated entirely on the server — markdown parsing, frontmatter validation, reading-time calculation. The only client JavaScript on the page is the search box. That's the trade RSC offers: you give up "everything is interactive by default" and get "nothing ships by default" in return. For content sites, it's a great trade.


