React Server Components Explained: What, Why and How to Use Them
On this page
React Server Components confused me for a solid month when they first landed. I kept thinking "isn't this just SSR?" — and if you've had that thought too, you're in good company. It took me building a few real projects with the Next.js App Router to properly understand why RSC is a fundamentally different concept. Let me save you the confusion.
TL;DR: A clear explanation of React Server Components: how they work, when to use them, and practical patterns for mixing server and client components.
What Are Server Components?
Server Components are React components that run exclusively on the server. They never execute in the browser, their code is never included in the client JavaScript bundle, and they can directly access server resources like databases and file systems.
// This component runs ONLY on the server
// It is never shipped to the browser
async function ProductPage({ params }: { params: { id: string } }) {
// Direct database access — no API endpoint needed
const product = await db.product.findUnique({
where: { id: params.id },
include: { reviews: true },
});
if (!product) notFound();
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>${product.price}</p>
<ReviewList reviews={product.reviews} />
<AddToCartButton productId={product.id} />
</div>
);
}
In this example, ProductPage and ReviewList can be Server Components — they just display data. AddToCartButton needs interactivity (click handlers, state), so it must be a Client Component. The key insight: most of your UI is just displaying data, and none of that display logic needs to run in the browser.
Server Components vs SSR
This is where I was confused for the longest time. They are not the same thing:
SSR (Server-Side Rendering):
- Renders HTML on the server for the initial page load
- Ships ALL component JavaScript to the client
- React "hydrates" the HTML to make it interactive
- The same code runs on both server and client
Server Components:
- Render on the server and stream the result as a special format (not HTML)
- Ship ZERO JavaScript for server components to the client
- Cannot use hooks, event handlers, or browser APIs
- Only run on the server — never in the browser
The key distinction: SSR is about rendering HTML faster. Server Components are about not shipping code at all. That "zero JavaScript" part was the lightbulb moment for me.
Why Server Components Matter
1. Smaller JavaScript Bundles
Every Server Component is code that never reaches the browser. In a typical Next.js application, 60-80% of components are display-only — they render data without any interactivity. With RSC, those components contribute zero bytes to the client bundle.
I measured this on a real project: a dashboard that went from 340KB of client JS to 120KB after properly separating server and client components. That's a massive improvement in load time, especially on mobile connections.
2. Direct Backend Access
This one genuinely changed how I think about building apps. No more building API endpoints just to fetch data for your UI:
// Before RSC: build an API, fetch from client
// GET /api/products → handler → database → JSON → client → render
// With RSC: just query the database
async function Products() {
const products = await db.product.findMany();
return <ProductGrid products={products} />;
}
Fewer files, fewer round trips, fewer things that can go wrong.
3. No Client-Server Waterfalls
With client-side data fetching, you get waterfall requests that kill your performance:
1. Browser downloads JavaScript (200ms)
2. React renders loading spinner (50ms)
3. Component mounts, fires fetch() (300ms)
4. Child component mounts, fires fetch() (300ms)
Total: 850ms
With Server Components, all data fetching happens on the server in parallel:
1. Server fetches all data in parallel (200ms)
2. Server streams rendered HTML (50ms)
3. Browser displays content (50ms)
Total: 300ms
That's nearly 3x faster for the user, and it's basically free — you just write your components normally.
The Mental Model: Server vs Client Components
Server Components (default in Next.js App Router)
// Server Component — no directive needed (it's the default)
async function Dashboard() {
const stats = await getStats();
const recentOrders = await getRecentOrders();
return (
<div>
<StatCards stats={stats} />
<OrderTable orders={recentOrders} />
</div>
);
}
Can do:
async/awaitfor data fetching- Access databases, file system, environment variables
- Import server-only packages (e.g.,
bcrypt,pg) - Keep secrets safe (API keys never reach the client)
Cannot do:
- Use
useState,useEffect,useRef, or any React hook - Add event handlers (
onClick,onChange) - Use browser APIs (
window,document,localStorage) - Use
createContextoruseContext
Client Components
'use client'; // This directive makes it a Client Component
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
);
}
Can do:
- Use hooks (
useState,useEffect, etc.) - Add event handlers
- Access browser APIs
- Use context
Cannot do:
- Be
async(no direct await in the component) - Access the file system or databases directly
Practical Patterns
Pattern 1: Server Component Wrapper with Client Interactivity
This is the pattern I use most. Keep the data fetching in a Server Component and pass data to a Client Component for the interactive bits:
// Server Component: fetches data
async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.product.findUnique({
where: { id: params.id },
});
return (
<div>
<h1>{product.name}</h1>
<ProductImageGallery images={product.images} /> {/* Client Component */}
<AddToCartForm product={product} /> {/* Client Component */}
</div>
);
}
// Client Component: handles interaction
'use client';
import { useState } from 'react';
function ProductImageGallery({ images }: { images: string[] }) {
const [activeIndex, setActiveIndex] = useState(0);
return (
<div>
<img src={images[activeIndex]} alt="" />
<div>
{images.map((img, i) => (
<button key={i} onClick={() => setActiveIndex(i)}>
<img src={img} alt="" />
</button>
))}
</div>
</div>
);
}
Pattern 2: Passing Server Components as Children
This took me a while to grok, but it's powerful. A Client Component can receive Server Components as children:
// Client Component: provides layout/interactivity
'use client';
import { useState } from 'react';
function Tabs({ tabs }: { tabs: { label: string; content: React.ReactNode }[] }) {
const [activeTab, setActiveTab] = useState(0);
return (
<div>
<div>
{tabs.map((tab, i) => (
<button key={i} onClick={() => setActiveTab(i)}>
{tab.label}
</button>
))}
</div>
<div>{tabs[activeTab].content}</div>
</div>
);
}
// Server Component: passes server-rendered content as children
async function ProductTabs({ productId }: { productId: string }) {
const [description, reviews] = await Promise.all([
getDescription(productId),
getReviews(productId),
]);
return (
<Tabs tabs={[
{ label: 'Description', content: <Description data={description} /> },
{ label: 'Reviews', content: <ReviewList reviews={reviews} /> },
]} />
);
}
Pattern 3: Server Actions for Mutations
Server Actions handle form submissions and data mutations without building separate API routes:
// Server Action (defined with 'use server')
'use server';
import { revalidatePath } from 'next/cache';
export async function addReview(formData: FormData) {
const productId = formData.get('productId') as string;
const rating = Number(formData.get('rating'));
const comment = formData.get('comment') as string;
await db.review.create({
data: { productId, rating, comment },
});
revalidatePath(`/products/${productId}`);
}
// Client Component uses the Server Action
'use client';
import { addReview } from './actions';
function ReviewForm({ productId }: { productId: string }) {
return (
<form action={addReview}>
<input type="hidden" name="productId" value={productId} />
<select name="rating">
<option value="5">5 stars</option>
<option value="4">4 stars</option>
<option value="3">3 stars</option>
</select>
<textarea name="comment" placeholder="Your review" />
<button type="submit">Submit</button>
</form>
);
}
Common Mistakes
I've made all three of these. Hopefully you can skip the debugging.
Mistake 1: Making Everything a Client Component
When I first started with the App Router, I slapped 'use client' on everything because it was easier than thinking about the boundary. Don't do this — it defeats the entire purpose of RSC. Start with Server Components (the default) and only add the directive when you actually need interactivity.
Mistake 2: Importing Server-Only Code in Client Components
'use client';
import { db } from '@/lib/db'; // This will break — db uses Node.js APIs
function UserList() {
// Cannot use db here — this runs in the browser
}
Use the server-only package to catch this at build time instead of at runtime:
// lib/db.ts
import 'server-only'; // Throws build error if imported from a Client Component
export const db = new PrismaClient();
This has saved me from shipping broken code to production more than once.
Mistake 3: Passing Functions as Props to Client Components
You cannot pass functions from Server Components to Client Components — functions aren't serializable:
// This will NOT work
async function Parent() {
const handleClick = () => console.log('clicked');
return <ChildClient onClick={handleClick} />; // Error!
}
Use Server Actions instead, or define the function inside the Client Component.
Key Takeaways
- Server Components run only on the server and ship zero JavaScript to the client
- They are different from SSR — RSC is about reducing client bundle size, not just rendering HTML faster
- Default to Server Components; add
'use client'only when you need hooks, events, or browser APIs - Pass data from Server Components to Client Components via props
- Use Server Actions for mutations
- Use the
server-onlypackage to prevent accidental server code in client bundles
Sources
Looking for more? Check out Adaptels.
Related Articles
Best Hosting for Next.js Apps in 2026: Vercel vs AWS vs Cloudflare
Compare Vercel, AWS, Cloudflare, and other Next.js hosting platforms. Benchmarks, pricing, and which platform wins for your use case in 2026.
How to Fix CORS Errors in Node.js and Express (Complete Guide)
Master CORS errors in Express. Learn what causes them, how to fix them, and best practices for production APIs with practical examples.
Next.js vs Remix in 2026: Which React Framework Should You Pick?
Compare Next.js and Remix in 2026: routing, performance, data loading, DX, and deployment. Which framework wins for your project?