A comprehensive deep dive into React Server Components — how they work, when to use them, composition patterns, performance benefits, and migration strategies.
Traditional React (CSR):
Browser downloads JS → Executes React → Renders UI → Fetches data → Re-renders
Server Components (RSC):
Server renders component → Sends HTML + RSC payload → Browser hydrates only interactive parts
The key insight: most of your UI doesn't need interactivity. Navigation, headers, data displays, lists — they're static once rendered. Only forms, buttons, and state-driven UI need JavaScript.
Server Components (default): Client Components ("use client"):
───────────────────────── ──────────────────────────────
✅ Direct database access ✅ useState, useEffect
✅ File system access ✅ Browser APIs (localStorage)
✅ Secret keys/env vars ✅ Event handlers (onClick)
✅ Zero JS sent to browser ✅ useRef, useContext
✅ async/await at component level ✅ Third-party hooks
❌ No hooks (useState, useEffect) ❌ No direct DB access
❌ No browser APIs ❌ All code ships to browser
// app/(admin)/admin/users/page.tsx — Server Component
import { prisma } from "@/lib/prisma";
import { UserTable } from "@admin/users/UserTable";
import { Pagination } from "@shared/Pagination";
export default async function UsersPage({
searchParams,
}: {
searchParams: Promise<{ page?: string; search?: string }>;
}) {
const params = await searchParams;
const page = Number(params.page) || 1;
const search = params.search || "";
const [users, total] = await Promise.all([
prisma.user.findMany({
where: {
deletedAt: null,
...(search && {
OR: [
{ name: { contains: search, mode: "insensitive" } },
{ email: { contains: search, mode: "insensitive" } },
],
}),
},
skip: (page - 1) * 20,
take: 20,
orderBy: { createdAt: "desc" },
}),
prisma.user.count({
where: { deletedAt: null },
}),
]);
return (
<div>
<h1>Users ({total})</h1>
<UserTable users={users} />
<Pagination total={total} pageSize={20} currentPage={page} />
</div>
);
}
// UserTable — Client Component (needs interactivity)
"use client";
import { useActionState } from "react";
import { deleteUser } from "@/actions/user.actions";
interface User {
id: string;
name: string;
email: string;
role: string;
}
export function UserTable({ users }: { users: User[] }) {
const [state, formAction] = useActionState(deleteUser, {});
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.role}</td>
<td>
<form action={formAction}>
<input type="hidden" name="id" value={user.id} />
<button type="submit">Delete</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
);
}
// actions/user.actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
export async function deleteUser(
prevState: { error?: string; success?: string },
formData: FormData
) {
const id = formData.get("id") as string;
await prisma.user.update({
where: { id },
data: { deletedAt: new Date() }, // Soft delete
});
revalidatePath("/admin/users");
return { success: "User deleted" };
}
✅ Server Component → Server Component (ALWAYS OK)
✅ Server Component → Client Component (ALWAYS OK)
✅ Client Component → Client Component (ALWAYS OK)
❌ Client Component → Server Component (NOT directly)
To pass Server Component INTO Client Component, use children:
// Layout (Server)
<ClientShell>
<ServerContent /> {/* Passed as children prop */}
</ClientShell>
// ClientShell (Client)
"use client";
export function ClientShell({ children }) {
return <div className="layout">{children}</div>;
}
Real measurements from our Developer Portfolio Platform:
| Metric | CSR (old) | RSC (current) | Improvement |
|---|---|---|---|
| First Contentful Paint | 2.1s | 0.8s | 62% faster |
| Total JS Bundle | 487KB | 156KB | 68% smaller |
| Time to Interactive | 3.4s | 1.2s | 65% faster |
| Lighthouse Score | 72 | 96 | +24 points |
server-only package to catch this// lib/prisma.ts
import "server-only"; // Throws if imported in client component
import { PrismaClient } from "@prisma/client";
Moving from Pages Router or client-heavy to RSC:
Step 1: Make layout server components (they rarely need state)
Step 2: Move data fetching from useEffect to server components
Step 3: Extract interactive parts into small client components
Step 4: Use Server Actions for form submissions
Step 5: Add Suspense boundaries for loading states
Related reads:
Follow on LinkedIn for React architecture insights.
RSC powers our entire platform. Developer Portfolio SaaS — React Server Components, 15+ modules, production-ready. $299.
Get the latest articles, tutorials, and updates delivered straight to your inbox. No spam, unsubscribe at any time.