When React Server Components (RSC) landed, I was genuinely confused. "Isn't this just SSR?" and "Where do I actually write these?" were questions I couldn't answer for a while.
This article explains RSC from the ground up — how it differs from SSR, when to use Server vs Client Components, and the patterns you'll use every day in a Next.js App Router project.
What Is RSC? How Does It Differ from SSR?
The one-sentence definition: RSC is a component that runs only on the server and sends zero JavaScript to the client.
SSR vs RSC
SSR (Server-Side Rendering) generates HTML on the server, sends it to the client, then loads JavaScript so React can "take over" — a process called hydration. All component code still ships to the browser.
RSC is fundamentally different:
| SSR | RSC | |
|---|---|---|
| Execution | Server (HTML) + Client (hydration) | Server only |
| JS sent to client | Yes — all component code | None |
| Interactivity | Yes (after hydration) | No |
| Direct DB access | Requires workarounds | Works natively |
| Bundle size impact | Yes | Zero |
RSC components execute on the server, produce HTML, and never appear as JavaScript in the client bundle. Even heavy npm libraries you import in RSC don't add to bundle size.
The Problem RSC Solves
// ❌ Traditional approach — data fetching on the client
"use client";
import { useEffect, useState } from "react";
function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch("/api/products").then(res => res.json()).then(setProducts);
}, []);
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
Problems here:
- Extra round-trip request from the client (waterfall)
- Loading state management required
- Component JavaScript bloats the bundle
// ✅ RSC approach
// app/products/page.tsx (Server Component by default in App Router)
import { db } from "@/lib/db";
async function ProductList() {
// Direct DB access — no API layer needed
const products = await db.product.findMany();
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
- No extra requests
- No loading state needed
- Zero JS sent to the client
Server vs Client Components: When to Use Each
In Next.js App Router, all components are Server Components by default. You opt into Client Components by adding the "use client" directive.
When You Need a Client Component
"use client"; // Required — without this, using hooks causes an error
import { useState, useEffect } from "react";
export function Counter() {
const [count, setCount] = useState(0); // State required
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
Use Client Components when you need:
- React hooks (
useState,useReducer,useEffect, etc.) - Browser APIs (
window,localStorage,navigator, etc.) - Event handlers (click, form submit, scroll, etc.)
- Animations (Framer Motion, etc.)
When Server Components Shine
// app/blog/[slug]/page.tsx
import { db } from "@/lib/db";
import { marked } from "marked"; // Heavy library — zero bundle cost
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// Direct DB query — no API route needed
const post = await db.post.findUnique({
where: { slug },
});
if (!post) return <div>Post not found</div>;
// marked runs on the server only — no JS shipped to client
const html = marked(post.content);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: html }} />
</article>
);
}
Server Components are ideal when you:
- Fetch data from a database or external API
- Need to access secrets (API keys, tokens)
- Want to use large libraries without bundle cost
- Only need to render HTML with no user interaction
Composing Server and Client Components
Here's the critical rule: Server Components can be passed as children to Client Components, but Client Components cannot directly import Server Components.
❌ Pattern That Doesn't Work
"use client";
// ❌ Importing a Server Component from a Client Component doesn't work
import { ServerSideWidget } from "./ServerSideWidget";
export function ClientShell() {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(o => !o)}>Toggle</button>
{open && <ServerSideWidget />} {/* ❌ Won't work as expected */}
</div>
);
}
✅ The Children Pattern
// ClientShell.tsx
"use client";
import { ReactNode, useState } from "react";
export function ClientShell({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(o => !o)}>Toggle</button>
{open && children} {/* ✅ Accept Server Component as children */}
</div>
);
}
// app/page.tsx (Server Component)
import { ClientShell } from "./ClientShell";
import { ServerSideWidget } from "./ServerSideWidget"; // Server Component
export default function Page() {
return (
// ✅ Compose in the Server Component layer
<ClientShell>
<ServerSideWidget />
</ClientShell>
);
}
The children pattern is the most important composition pattern in RSC. Create "slots" (children) in Client Components and pass Server Components into them from the Server Component layer.
Typical Project Structure
app/
├── page.tsx # Server Component (data fetching)
├── components/
│ ├── ProductCard.tsx # Server Component (static display)
│ ├── AddToCart.tsx # "use client" (click handler needed)
│ └── SearchBox.tsx # "use client" (input state)
// app/page.tsx
import { db } from "@/lib/db";
import { ProductCard } from "./components/ProductCard";
import { SearchBox } from "./components/SearchBox";
export default async function HomePage() {
const products = await db.product.findMany({ take: 10 });
return (
<main>
<SearchBox /> {/* Client Component */}
<div className="grid grid-cols-3 gap-4">
{products.map(product => (
<ProductCard key={product.id} product={product}>
{/* AddToCart is a Client Component, but product data flows from the server */}
<AddToCart productId={product.id} />
</ProductCard>
))}
</div>
</main>
);
}
Server Actions
RSC pairs naturally with Server Actions — server-side functions that handle form submissions and mutations.
// app/actions.ts
"use server"; // Server Action directive
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function addProduct(formData: FormData) {
const name = formData.get("name") as string;
const price = Number(formData.get("price"));
await db.product.create({ data: { name, price } });
revalidatePath("/products"); // Invalidate cache to trigger re-render
}
// app/products/new/page.tsx (Server Component)
import { addProduct } from "../actions";
export default function NewProductPage() {
return (
<form action={addProduct}>
<input name="name" placeholder="Product name" />
<input name="price" type="number" placeholder="Price" />
<button type="submit">Add Product</button>
</form>
);
}
Suspense and Streaming
RSC works with React Suspense to enable streaming rendering. You can show the page immediately and stream in expensive sections as data becomes available.
// app/dashboard/page.tsx
import { Suspense } from "react";
import { HeavyStats } from "./components/HeavyStats";
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* Renders immediately */}
<QuickSummary />
{/* Streams in when HeavyStats finishes fetching */}
<Suspense fallback={<div>Loading stats...</div>}>
<HeavyStats />
</Suspense>
</div>
);
}
// app/dashboard/components/HeavyStats.tsx (Server Component)
async function HeavyStats() {
// Even if this takes 2 seconds, the rest of the page is unblocked
const stats = await fetchExpensiveStats();
return (
<div>
<p>Revenue: {stats.revenue}</p>
<p>Users: {stats.users}</p>
</div>
);
}
The page shell renders and ships to the browser immediately. HeavyStats streams in when ready — without blocking the entire page load.
Wrapping Up
React Server Components are components that run on the server only and ship zero JavaScript to the client.
| Use Case | Server Component | Client Component |
|---|---|---|
| Data fetching | ✅ | Avoid |
| Direct DB access | ✅ | ❌ Not possible |
| Secrets / API keys | ✅ | ❌ Dangerous |
| useState / useEffect | ❌ Not possible | ✅ |
| Event handlers | ❌ Not possible | ✅ |
| Browser APIs | ❌ Not possible | ✅ |
In App Router, think "Server by default, Client only when necessary." When you need hooks or event handlers, add "use client" at that point.
Master the children composition pattern, and you can freely mix interactive Client Components with server-powered data fetching. RSC reduces bundle size and eliminates most API layer boilerplate — well worth understanding deeply.