React Server Components (RSC) are one of those features that sounds abstract until it clicks — and once it does, you start seeing your entire component tree differently.
The Core Idea
Before RSC, every React component ran in the browser. The server might render an initial HTML snapshot, but the JavaScript for all your components was shipped to the client and re-executed there.
RSC introduces a new category: components that only ever run on the server. They never ship their code to the browser. They can't use useState, useEffect, or any browser API — but they can do things client components can't:
- Read directly from a database
- Access the filesystem
- Use secrets without exposing them
- Import heavy libraries without adding to your bundle
// This component never reaches the browser
// The DB query, the import — none of it ships to the client
import { db } from "@/lib/db";
import { marked } from "marked"; // heavy library, zero bundle cost
export default async function ArticlePage({ slug }: { slug: string }) {
const article = await db.articles.findOne({ slug });
const html = marked(article.content);
return <article dangerouslySetInnerHTML={{ __html: html }} />;
}
The Mental Model
Think of your component tree as having two zones:
Server Zone │ Client Zone
─────────────────────────────│──────────────────────
Layout │
└─ ArticlePage (async!) │
└─ TableOfContents │
└─ ─ ─ ─ ─ ─ ─ ─│─ ▶ InteractiveDemo ("use client")
│ └─ CodeBlock
│ └─ CopyButton
Server components can render client components. But client components cannot render server components (they've already left the server by that point).
When to Use Which
Default to Server Components for:
- Pages and layouts
- Data fetching
- Static content rendering
- Anything that reads from a DB or API
Opt into Client Components ("use client") for:
- Interactivity (
onClick, forms, toggles) - Browser APIs (
localStorage,window) - React hooks (
useState,useEffect,useContext)
The Gotcha Everyone Hits
You can't pass non-serializable props from a server component to a client component. Functions, class instances, and complex objects won't cross the boundary.
// ❌ This breaks — functions aren't serializable
<ClientComponent onClick={() => doSomething()} />
// ✅ This works — define the handler inside the client component
<ClientComponent itemId={item.id} />
Why This Matters for Performance
The real win is bundle size. A markdown parser, a date formatting library, a syntax highlighter — if they only live in server components, they contribute zero bytes to your JavaScript bundle.
For content-heavy sites, this is a substantial difference in load time, especially on mobile.
RSC is a shift in how you think about components, not just an optimization. Once you internalize the server/client boundary, the architecture of a Next.js app starts feeling much more intentional.