TLDR: RSC is not SSR with extra steps — it's a different rendering model where server components live only on the server and never ship JavaScript to the browser. The client/server boundary rule trips everyone up; the children prop is the escape hatch. Real bundle wins when you stop shipping parsing libraries to every user.
React Server Components landed quietly and immediately split the frontend community. Half the people I know called it brilliant. The other half called it confusing and unnecessary. After building with it seriously for about six months, I think both camps are partially right — and the confusion mostly comes from one mental model error.
RSC is not "server-side rendering with extra steps." It's a fundamentally different way of thinking about where your component tree lives. Once that clicks, the whole thing makes sense.
The problem it solves
Before RSC, every component ended up in the JavaScript bundle your users downloaded — even components that did nothing interactive. A date formatter. A markdown renderer. A component that just fetches data and passes it to children. All of it shipped to the browser, parsed, and executed on every page load.
The workaround was to do data fetching at the route level and prop-drill everything down:
// Old pattern: fetch once at the top, drill props everywhere
export async function getServerSideProps() {
const data = await db.query('SELECT * FROM posts');
return { props: { posts: data } };
}
This works. It also creates waterfalls — you fetch at the route, render the parent, then discover you need data for a child, and now you're in another fetch. The further you push data down, the more fetching you do.
RSC lets you co-locate data fetching with the component that uses it, on the server, where it's close to your database:
// Server Component — runs only on the server, zero JS to the client
async function PostList() {
const posts = await db.query('SELECT * FROM posts');
return (
<ul>
{posts.map(post => <PostItem key={post.id} post={post} />)}
</ul>
);
}
No prop drilling. No waterfall. No JS shipped to the browser for this component.
Two worlds, one tree
RSC introduces a strict boundary between server and client components:
| Server Component | Client Component | |
|---|---|---|
| Runs on | Server only | Browser (+ optional SSR) |
| Access to | DB, filesystem, secrets | State, effects, browser APIs |
| Sends to client | Serialized React tree | JavaScript bundle |
| Directive | (default — no directive needed) | 'use client' at the top |
The important thing: server components are the default. You opt in to client behavior with 'use client', not the other way around.
The server component tree is serialized into a special wire format and streamed to the client. Client components hydrate against this stream. Server components never re-render in the browser — they don't exist there.
The boundary rule that trips everyone up
You can nest client components inside server components. You cannot directly nest server components inside client components.
// ✅ Valid: Server component renders a Client component
async function ServerPage() {
const data = await fetchData();
return (
<div>
<h1>{data.title}</h1>
<InteractiveButton label="Click me" /> {/* 'use client' */}
</div>
);
}
// ❌ Breaks: Client component tries to import a Server component
'use client';
import { ServerThing } from './ServerThing'; // This won't work
export function ClientLayout() {
const [open, setOpen] = useState(false);
return <ServerThing />; // Can't do this
}
The escape hatch is children:
// ✅ Valid: Pass Server Components as children props
'use client';
export function Layout({ children }) {
const [open, setOpen] = useState(false);
return (
<div>
<Sidebar open={open} onToggle={() => setOpen(!open)} />
{children} {/* This subtree can be a Server Component */}
</div>
);
}
A client component can render a server component if it receives it as a prop or as children. The server component subtree is already resolved before it reaches the client boundary. This pattern is how you build interactive wrappers around server-rendered content — layouts, drawers, modals.
The performance wins
Bundle size: Any library used exclusively in a server component never ships to the client. This is bigger than it sounds.
// These libraries stay on the server — zero bytes to the browser
import { marked } from 'marked'; // ~50kB
import hljs from 'highlight.js'; // ~900kB
async function BlogPost({ slug }) {
const raw = await readFile(`posts/${slug}.md`, 'utf8');
const html = marked(raw);
return <article dangerouslySetInnerHTML={{ __html: html }} />;
}
Nearly a megabyte of parsing libraries that your users never have to download. On a content site, this compounds significantly.
Waterfall elimination: Server components run close to your data — same datacenter, often same machine. Sequential fetches that used to require two or three network round trips become Promise.all calls with sub-millisecond overhead:
async function Dashboard({ userId }) {
const [user, orders, recommendations] = await Promise.all([
db.users.find(userId),
db.orders.findByUser(userId),
getRecommendations(userId),
]);
return (
<div>
<UserHeader user={user} />
<OrderList orders={orders} />
<Recommendations items={recommendations} />
</div>
);
}
LCP improvement: Non-destructive hydration (React 18+) means the server renders full HTML, the browser displays it immediately, and React hydrates in the background without tearing down and rebuilding the DOM. Users see content before JavaScript executes. This directly moves your Largest Contentful Paint metric.
Where it gets complicated
RSC works beautifully in Next.js App Router. Outside of that, it's still rough — the framework has to understand the component model deeply to split the bundle correctly at build time. Vite setups and custom configurations are plumbing-heavy.
There's also the muscle memory problem. Most React developers have years of useEffect for data fetching. RSC doesn't remove that pattern — but it demotes it to a last resort for genuinely browser-only data needs, not a default for all async work.
Serialization catches people off guard. Only serializable values can cross the server→client boundary as props — strings, numbers, plain objects, arrays. Dates, Maps, class instances, functions — they need special handling or they won't cross the boundary at all.
How I actually use it
A few rules I've settled on after six months:
Default to server components. Add 'use client' only when you actually need interactivity, browser APIs, or event handlers. Most components in most applications don't need any of those things. Push the 'use client' boundary as deep into the tree as possible.
Treat server components like database access layers. They fetch data and pass it down. They don't orchestrate complex UI logic. Keep them thin.
When you hit the serialization wall, it's usually a sign you're passing something that should be fetched client-side anyway, or that you need to restructure so a server component passes simple data and a client component handles the complex object.
RSC is the right direction. It's React finally acknowledging that components don't all belong in the same runtime. The execution model is more complex — but smaller bundles, faster initial loads, and no client-side waterfalls are worth getting comfortable with the mental model shift.