React Server Components (RSC) are the biggest paradigm shift in React since hooks. But after two years in production, there's still confusion about what they actually are — and more importantly, when not to use them.
This guide cuts through the noise.
What Problem Do They Solve?
Before RSC, every React component ran in the browser. Even if you fetched data on the server (via getServerSideProps or loaders), the component that rendered that data still shipped its JavaScript to the client.
This created a hidden cost: bundle bloat. Libraries imported in components — markdown parsers, date formatters, data validators — all went to the browser, even when they were only needed during rendering.
RSC solves this by running components on the server and sending only the rendered output (HTML + a serialized description) to the client.
[!NOTE] Server Components never run in the browser. They produce output once — at request time or build time — and the result is streamed to the client. Their JavaScript is never sent to the browser.
The Mental Model
Think of your component tree as split into two worlds:
App (Server)
├── Layout (Server)
├── Header (Server)
├── Page (Server)
│ ├── ArticleContent (Server) ← reads MDX from disk
│ ├── TableOfContents (Client) ← uses useEffect + IntersectionObserver
│ └── ShareButtons (Client) ← uses useState, navigator.clipboard
└── Footer (Server)
The rule: push interactivity to the leaves. Most of your tree should be Server Components. Only the parts that need browser APIs, state, or event handlers need to be Client Components.
Async Server Components
The killer feature is async/await directly in your component:
// This component never ships to the browser
export default async function ArticlePage({ params }: { params: { slug: string } }) {
// Direct filesystem access — no API layer needed
const article = await fs.readFile(`content/${params.slug}.mdx`, 'utf-8');
const { data, content } = matter(article);
// This import is never in the client bundle
const { remark } = await import('remark');
return (
<article>
<h1>{data.title}</h1>
<div dangerouslySetInnerHTML={{ __html: content }} />
</article>
);
}
Notice: remark, gray-matter, and fs — none of these end up in your client bundle.
Crossing the Boundary
Passing data from Server to Client Components is straightforward but has one critical rule: props must be serializable.
// Server Component
export default async function ProductPage() {
const product = await db.products.find({ id: 1 });
return (
<div>
<ProductImages images={product.images} /> {/* Server: no interactivity */}
<AddToCart productId={product.id} price={product.price} /> {/* Client: needs state */}
</div>
);
}
// Client Component
"use client";
export function AddToCart({ productId, price }: { productId: number; price: number }) {
const [quantity, setQuantity] = useState(1);
// ...
}
[!TIP] You cannot pass a class instance, a function, or a Date object as a prop from Server to Client. Serialize to primitives (strings, numbers, arrays, plain objects) before crossing the boundary.
Common Mistake: The "Everything Server" Trap
New RSC adopters often overcorrect and try to make everything a Server Component. This breaks when you need:
useState/useReducer/useContextuseEffect/useLayoutEffect- Browser APIs (
window,document,navigator) - Event handlers (
onClick,onChange) - Third-party client libraries (charts, drag-and-drop, animations)
The fix is simple: extract the interactive piece into its own "use client" component.
Data Fetching Patterns
Pattern 1: Fetch at the page level
// app/dashboard/page.tsx (Server)
export default async function DashboardPage() {
const [user, stats, recentActivity] = await Promise.all([
fetchUser(),
fetchStats(),
fetchActivity(),
]);
return <Dashboard user={user} stats={stats} activity={recentActivity} />;
}
Pattern 2: Fetch inside components
// Each component fetches what it needs — Next.js deduplicates identical requests
async function UserAvatar({ userId }: { userId: string }) {
const user = await fetchUser(userId); // Cached and deduped by React
return <img src={user.avatar} alt={user.name} />;
}
Pattern 3: Streaming with Suspense
export default function Page() {
return (
<>
<Suspense fallback={<HeaderSkeleton />}>
<Header /> {/* Slow — resolves in 50ms */}
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<Feed /> {/* Slow — resolves in 800ms */}
</Suspense>
</>
);
}
With Suspense, the fast parts stream first. Users see a skeleton for slow parts instead of a blank page.
Performance Impact
In production, RSC can dramatically reduce bundle sizes. A typical content site that adopted RSC saw:
| Metric | Before RSC | After RSC |
|---|---|---|
| JS bundle (gzip) | 178KB | 52KB |
| Time to Interactive | 3.8s | 1.2s |
| Largest Contentful Paint | 2.9s | 1.0s |
The reduction comes from libraries like date-fns, marked, and schema validators no longer shipping to the browser.
Summary
Server Components are not a silver bullet — they're a new primitive that handles a specific job: rendering data-heavy UI on the server without sending that logic to the browser.
Use them by default. Add "use client" only when you need it. Keep your interactivity at the leaves. That's the entire mental model.
Tools in this post
Related Tool
JSON Parser & Formatter
Validate, format, and minify JSON data with error highlighting.
Try it freeWritten by
DebuggerMe TeamThe DebuggerMe team builds developer tools, writes technical content, and helps teams ship better software.
Related Articles
All articles →Getting Started with Next.js 16 — A Complete Guide
Everything you need to know to build fast, modern web applications with Next.js 16 App Router, Server Components, and TypeScript. From project setup to production deployment.
Why TypeScript Generics Are More Powerful Than You Think
A deep dive into TypeScript's generic type system — from basic usage to advanced patterns like conditional types, infer, and mapped types that will make your code safer and more expressive.
Stop Writing API Wrappers — Use TanStack Query Instead
Most frontend codebases have a homegrown API layer full of useEffect hacks, loading booleans, and stale data bugs. TanStack Query solves all of these in 20 lines. Here's how to migrate.