React Server Components (RSC) fundamentally change how we think about building React apps. They’re not just a performance optimization — they’re a new mental model for where code runs.
Traditional React: everything runs on the client.
Browser loads JS → React hydrates → Components render → User sees content
With RSC: some components run on the server, some on the client.
Server renders RSC → Streams HTML + RSC payload → Client hydrates only interactive parts
The result? Less JavaScript shipped, faster initial load, and direct access to backend resources.
Here’s the decision framework I use:
| Use Server Component When… | Use Client Component When… |
|---|---|
| Fetching data | Handling user interactions |
| Accessing backend resources | Using browser APIs |
| Keeping secrets server-side | Managing local state |
| Rendering static content | Using effects or refs |
| Large dependencies (markdown, syntax highlighting) | Animations |
// app/blog/[slug]/page.tsx
// This is a Server Component by default (no "use client")
import { db } from "@/lib/database";
import { formatDate } from "@/lib/utils";
import { CommentSection } from "./comment-section"; // Client component
interface Props {
params: { slug: string };
}
export default async function BlogPost({ params }: Props) {
// Direct database access — no API needed!
const post = await db.posts.findUnique({
where: { slug: params.slug },
include: { author: true, tags: true },
});
if (!post) return notFound();
return (
<article className="max-w-3xl mx-auto">
<header>
<span className="text-sm text-gray-500">
{formatDate(post.publishedAt)}
</span>
<h1 className="text-4xl font-bold mt-2">{post.title}</h1>
<div className="flex items-center gap-3 mt-4">
<img
src={post.author.avatar}
alt={post.author.name}
className="w-10 h-10 rounded-full"
/>
<span className="text-gray-700">{post.author.name}</span>
</div>
</header>
<div className="prose mt-8" dangerouslySetInnerHTML={{
__html: post.contentHtml
}} />
{/* Client component for interactivity */}
<CommentSection postId={post.id} />
</article>
);
}
"use client";
// app/blog/[slug]/comment-section.tsx
import { useState, useTransition } from "react";
import { submitComment } from "./actions";
interface Props {
postId: string;
}
export function CommentSection({ postId }: Props) {
const [comment, setComment] = useState("");
const [isPending, startTransition] = useTransition();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
startTransition(async () => {
await submitComment(postId, comment);
setComment("");
});
};
return (
<section className="mt-12 border-t pt-8">
<h2 className="text-2xl font-bold">Comments</h2>
<form onSubmit={handleSubmit} className="mt-4">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Write a comment..."
className="w-full p-3 border rounded-lg resize-none"
rows={4}
/>
<button
type="submit"
disabled={isPending}
className="mt-2 px-4 py-2 bg-blue-600 text-white rounded-lg
disabled:opacity-50"
>
{isPending ? "Posting..." : "Post Comment"}
</button>
</form>
</section>
);
}
Notice the "use client" directive at the top — this is the boundary marker.
One of the most powerful features of RSC is streaming. Instead of waiting for all data to load, the server streams HTML as it becomes available:
import { Suspense } from "react";
export default function Dashboard() {
return (
<div className="grid grid-cols-3 gap-6">
{/* These load independently and stream in as ready */}
<Suspense fallback={<Skeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<Skeleton />}>
<RecentOrders />
</Suspense>
<Suspense fallback={<Skeleton />}>
<UserActivity />
</Suspense>
</div>
);
}
async function RevenueChart() {
// This might take 2 seconds...
const data = await fetchRevenueData();
return <Chart data={data} />;
}
async function RecentOrders() {
// This might take 500ms...
const orders = await fetchRecentOrders();
return <OrderList orders={orders} />;
}
The browser shows each section as soon as it’s ready, not after the slowest one finishes.
Time 0ms 200ms 500ms 1000ms 2000ms
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
Shell Nav Orders Activity Revenue
████ ████ ████████ ████████ ████████████
▲ streams in ▲ streams in
// ✅ Good: Fetch in parallel
async function Page() {
const [posts, categories, user] = await Promise.all([
fetchPosts(),
fetchCategories(),
fetchUser(),
]);
return <Dashboard posts={posts} categories={categories} user={user} />;
}
// ❌ Bad: Sequential waterfall
async function Page() {
const user = await fetchUser(); // 200ms
const posts = await fetchPosts(user.id); // 300ms (waits for user)
const comments = await fetchComments(); // 150ms (waits for posts)
// Total: 650ms
}
// ✅ Better: Preload pattern
import { preload } from "./data";
export default async function Page({ params }) {
// Start fetching immediately — don't await yet
preload(params.id);
// Do other work...
const config = await getConfig();
// Now use the preloaded data
const data = await getData(params.id); // Already cached!
return <Component data={data} config={config} />;
}
I benchmarked a real blog app with and without RSC:
| Metric | Client-Side | RSC | Improvement |
|---|---|---|---|
| First Contentful Paint | 1.8s | 0.6s | 67% faster |
| Largest Contentful Paint | 3.2s | 1.1s | 66% faster |
| Total JS Bundle | 245 KB | 89 KB | 64% smaller |
| Time to Interactive | 4.1s | 1.4s | 66% faster |
| Lighthouse Score | 72 | 96 | +24 points |
These numbers are from a content-heavy blog with syntax highlighting, markdown rendering, and an image gallery. The more static content you have, the bigger the RSC wins.
// ❌ Don't do this — defeats the purpose of RSC
"use client";
export default function BlogPost({ post }) {
return <div>{post.title}</div>; // No interactivity needed!
}
// ❌ Can't pass functions from Server → Client
<ClientComponent onClick={() => console.log("hi")} />
// ✅ Use Server Actions instead
<ClientComponent action={serverAction} />
// ❌ This will error
import { motion } from "framer-motion"; // Client-only library
export default function Page() {
return <motion.div>...</motion.div>;
}
// ✅ Wrap in a client component
// animation-wrapper.tsx
"use client";
import { motion } from "framer-motion";
export function AnimatedDiv({ children }) {
return <motion.div>{children}</motion.div>;
}
Think of your component tree as a river:
"use client" directive is the waterline — everything below it is interactive Server (static, fast)
─────────────────────────────────────────────
"use client" ← waterline
─────────────────────────────────────────────
Client (interactive, JS)
RSC isn’t the answer to everything:
RSC is the biggest shift in React since hooks. The key takeaways:
"use client" when you need interactivityPromise.allNext up: Building a full-stack app with RSC, Server Actions, and edge deployment.
Get notified when I publish new posts. No spam, unsubscribe anytime.