React Server Components (RSC) represent the biggest architectural shift in React since hooks. They fundamentally change where and how your components execute. Let me break down the mental model.
The Core Idea
Traditional React: All components run in the browser (with optional SSR for initial HTML).
Server Components: Components can run on the server and never ship their JavaScript to the client.
This isn't SSR. SSR renders your component tree to HTML on the server, then hydrates it on the client (running the same code again). Server Components stay on the server permanently.
The Two Worlds
┌─────────────────────────────────────────┐
│ SERVER │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Page │ │ List │ │ Query │ │
│ │ (server)│ │ (server)│ │ (server)│ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
└───────┼─────────────┼─────────────┼──────┘
│ │ │
┌───────┼─────────────┼─────────────┼──────┐
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Counter │ │ Modal │ │ Form │ │
│ │(client) │ │(client) │ │(client) │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ CLIENT │
└─────────────────────────────────────────┘What Server Components Can Do
// This component NEVER runs in the browser
// It doesn't add to your JS bundle
async function BlogPost({ slug }: { slug: string }) {
// Direct database access — no API layer needed
const post = await db.posts.findUnique({
where: { slug },
include: { author: true, comments: true },
});
if (!post) notFound();
// Access server-only resources
const analytics = await getAnalytics(post.id);
// Import heavy libraries that won't be in the client bundle
const { format } = await import("date-fns");
const formattedDate = format(post.createdAt, "MMMM d, yyyy");
return (
<article>
<h1>{post.title}</h1>
<time>{formattedDate}</time>
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
{/* This client component handles interactivity */}
<CommentSection postId={post.id} initialComments={post.comments} />
</article>
);
}The Serialization Boundary
When a Server Component renders a Client Component, it passes props across the serialization boundary. Only serializable values can cross:
// ✅ These work as props to client components
<ClientComponent
name="Mukesh" // string
count={42} // number
items={["a", "b"]} // array
config={{ key: "val" }} // plain object
date="2024-01-01" // string (not Date object!)
/>
// ❌ These DON'T work
<ClientComponent
onClick={() => {}} // functions can't be serialized
element={<div />} // JSX from server → actually works!
class={new MyClass()} // class instances
ref={myRef} // refs
/>Composition Pattern: The Slot Pattern
One of the most powerful patterns with RSC is passing server-rendered content as children to client components:
// Server Component
async function Dashboard() {
const data = await fetchDashboardData();
return (
<InteractiveLayout>
{/* These are server-rendered but displayed inside a client component */}
<MetricsPanel data={data.metrics} />
<RecentActivity items={data.activity} />
<UserStats stats={data.stats} />
</InteractiveLayout>
);
}
// Client Component — handles drag-and-drop, resize, etc.
"use client";
function InteractiveLayout({ children }: { children: React.ReactNode }) {
const [layout, setLayout] = useState("grid");
return (
<div className={layout}>
{children} {/* Server-rendered content, zero JS cost */}
</div>
);
}When to Use Client vs Server Components
| Use Server Components when... | Use Client Components when... |
|---|---|
| Fetching data | Using useState, useEffect |
| Accessing backend resources | Event listeners (onClick, onChange) |
| Keeping secrets on server | Browser APIs (localStorage, etc.) |
| Large dependencies (markdown, etc.) | Interactive UI (modals, dropdowns) |
| No interactivity needed | Real-time updates |
Performance Implications
The bundle size difference is dramatic. Consider a blog page:
Traditional SPA:
- react-markdown: 45KB
- date-fns: 20KB
- highlight.js: 80KB
- Component code: 5KB
Total JS shipped: ~150KB
With Server Components:
- Component code: 0KB (runs on server)
- Client interactivity: ~3KB (share button, comments)
Total JS shipped: ~3KBThat's a 98% reduction in JavaScript for a content-heavy page.
Streaming and Suspense
Server Components integrate beautifully with streaming:
async function Page() {
return (
<div>
<Header /> {/* Renders immediately */}
<Suspense fallback={<PostSkeleton />}>
<BlogPost slug={params.slug} /> {/* Streams in when ready */}
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments postId={postId} /> {/* Streams independently */}
</Suspense>
</div>
);
}Each Suspense boundary is an independent streaming chunk. The user sees content progressively, with no layout shift.
Conclusion
Server Components aren't just an optimization — they're a new architecture. They let you build apps where:
- Most code runs on the server (close to the data)
- Only interactive bits ship JavaScript
- The composition model is still just React
The mental model shift takes time, but once it clicks, you'll wonder how we lived without it.