React Server Components confused most of the React community when they launched. The mental model shift is real — after a decade of "everything runs in the browser," RSC asks you to think about where each component renders. But once it clicks, it's a genuinely better architecture for most web applications.
We've migrated two client projects to Next.js App Router (which uses RSC) at Pillai Infotech. One went smoothly. One was painful. This guide covers the lessons from both.
What React Server Components Actually Do
React Server Components render on the server and send the result to the client as a serialized React tree — not HTML, but a compact representation that React can merge into the existing component tree without losing client-side state.
The key difference from traditional SSR:
- Traditional SSR: Render HTML on server → send to client → hydrate (re-attach JavaScript to every component)
- RSC: Server Components render on server and stay on the server. Only Client Components are hydrated. Server Component code never ships to the browser.
The Mental Model: Think in Boundaries
In the RSC world, your component tree has a boundary between server and client. Everything above the boundary runs on the server. Everything below runs on the client.
// app/page.tsx — Server Component (default in Next.js App Router)
// Can: access database, read files, use secrets
// Cannot: useState, useEffect, onClick, browser APIs
import { db } from '@/lib/db';
import { ProductList } from './product-list'; // Server Component
import { AddToCart } from './add-to-cart'; // Client Component
export default async function ProductPage() {
// This runs on the server — direct DB access, no API needed
const products = await db.product.findMany({
where: { active: true }
});
return (
<main>
<h1>Products</h1>
<ProductList products={products} />
{/* Client boundary starts here */}
<AddToCart products={products} />
</main>
);
}
// app/add-to-cart.tsx — Client Component
'use client'; // This directive marks the boundary
import { useState } from 'react';
export function AddToCart({ products }) {
const [cart, setCart] = useState([]);
return (
<button onClick={() => setCart(prev => [...prev, products[0]])}>
Add to Cart ({cart.length})
</button>
);
}
Server vs Client Components: When to Use Each
| Need | Server Component | Client Component |
|---|---|---|
| Fetch data from DB/API | ✔ Direct access | Via fetch/SWR |
| Use secrets/env vars | ✔ Safe | ✘ Never |
| useState / useEffect | ✘ | ✔ |
| Event handlers (onClick) | ✘ | ✔ |
| Browser APIs (window, localStorage) | ✘ | ✔ |
| Render static content | ✔ Zero JS shipped | JS hydration overhead |
| Heavy dependencies (markdown, charts) | ✔ Keeps bundle small | Ships entire library |
Rule of thumb: Start with Server Components. Only add 'use client' when you need interactivity (state, effects, event handlers, browser APIs). Push the client boundary as far down the component tree as possible.
Data Fetching Patterns
// Pattern 1: Async Server Component (recommended)
async function UserProfile({ userId }: { userId: string }) {
const user = await db.user.findUnique({ where: { id: userId } });
return <div>{user.name}</div>;
}
// Pattern 2: Parallel data fetching
async function Dashboard() {
// These run in parallel — no waterfall
const [stats, orders, users] = await Promise.all([
getStats(),
getRecentOrders(),
getActiveUsers(),
]);
return (
<>
<StatsPanel stats={stats} />
<OrdersTable orders={orders} />
<UsersList users={users} />
</>
);
}
// Pattern 3: Streaming with Suspense
export default function Page() {
return (
<main>
<h1>Dashboard</h1>
{/* Shows immediately */}
<FastStats />
{/* Streams in when ready — shows loading skeleton first */}
<Suspense fallback={<OrdersSkeleton />}>
<SlowOrders />
</Suspense>
</main>
);
}
Server Actions: Mutations Without API Routes
// Server Action — runs on the server, called from client
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
const schema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
});
export async function createPost(formData: FormData) {
const data = schema.parse({
title: formData.get('title'),
content: formData.get('content'),
});
await db.post.create({ data });
revalidatePath('/posts'); // Refresh the page data
}
// Usage in a Client Component
'use client';
import { createPost } from './actions';
function NewPostForm() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create Post</button>
</form>
);
// Works without JavaScript! Progressive enhancement built in.
}
Performance Impact
| Metric | Before RSC (Pages Router) | After RSC (App Router) |
|---|---|---|
| JS Bundle (typical page) | ~120-200 KB | ~60-100 KB |
| TTFB | ~200-400ms | ~100-200ms (streaming) |
| LCP | ~1.8-2.5s | ~1.0-1.5s |
| Data fetching waterfalls | Common (client-side) | Eliminated (server-side) |
For more on optimizing these metrics, see our web performance optimization guide.
Common Pitfalls
- Making everything a Client Component. Adding
'use client'to the root layout defeats the purpose. Only interactive components need to be Client Components. - Passing functions as props across the boundary. You can't pass functions from Server to Client Components — only serializable data (strings, numbers, objects, arrays).
- Over-fetching in layouts. Layout components render once and don't re-fetch on navigation. Don't put page-specific data fetching in layouts.
- Ignoring caching. Next.js caches aggressively. Understanding
revalidatePath,revalidateTag, andunstable_cacheis essential for correct data freshness. - Not using Suspense boundaries. Without Suspense, slow data fetching blocks the entire page. Wrap slow components in Suspense for streaming.