React Server Components (RSC) have fundamentally changed how we build React applications. By rendering components on the server and streaming HTML to the client, RSC dramatically reduces JavaScript bundle sizes, improves initial load times, and simplifies data fetching. If you’re still wrapping every component in useEffect calls, it’s time to level up.
In this guide, we’ll explore how React Server Components work in 2026, when to use them, and how to build a real feature using both server and client components together.
What Are React Server Components?
Server Components are React components that execute only on the server. They never ship JavaScript to the browser. This means you can safely use Node.js APIs, query databases directly, read files, and call internal services — all inside your component code.
The key mental model: Server Components handle data, Client Components handle interactivity.
Server vs Client Components: Quick Comparison
| Feature | Server Component | Client Component |
|---|---|---|
| Runs on | Server only | Browser (+ server for SSR) |
| JavaScript sent to client | None | Yes |
| Can use hooks (useState, useEffect) | No | Yes |
| Can access DB/filesystem | Yes | No |
| Can handle click events | No | Yes |
Setting Up: Next.js App Router
The most mature RSC implementation is in Next.js (App Router). In Next.js 15+, every component inside the app/ directory is a Server Component by default.
// app/page.tsx — This is a Server Component by default
import { db } from '@/lib/database';
export default async function HomePage() {
const posts = await db.posts.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
});
return (
<main>
<h1>Latest Posts</h1>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</main>
);
}Notice: no useEffect, no useState, no loading spinners. The database query runs on the server, and the fully rendered HTML is sent to the client. Zero client-side JavaScript for this component.
Adding Interactivity with Client Components
When you need user interaction — clicks, form inputs, animations — you opt into a Client Component with the 'use client' directive:
// components/LikeButton.tsx
'use client';
import { useState } from 'react';
export function LikeButton({ postId, initialCount }: {
postId: string;
initialCount: number;
}) {
const [count, setCount] = useState(initialCount);
const [isLiked, setIsLiked] = useState(false);
async function handleLike() {
setIsLiked(!isLiked);
setCount((prev) => (isLiked ? prev - 1 : prev + 1));
await fetch(`/api/posts/${postId}/like`, {
method: 'POST',
});
}
return (
<button onClick={handleLike}>
{isLiked ? '❤️' : '🤍'} {count} likes
</button>
);
}The Composition Pattern: Mixing Server and Client
The real power emerges when you compose server and client components together. The rule is simple: Server Components can import Client Components, but not vice versa.
// app/posts/[id]/page.tsx — Server Component
import { db } from '@/lib/database';
import { LikeButton } from '@/components/LikeButton';
import { CommentSection } from '@/components/CommentSection';
export default async function PostPage({
params,
}: {
params: { id: string };
}) {
const post = await db.posts.findUnique({
where: { id: params.id },
include: { author: true, _count: { select: { likes: true } } },
});
if (!post) return <p>Post not found</p>;
return (
<article>
<h1>{post.title}</h1>
<p className="meta">
By {post.author.name} · {post.createdAt.toLocaleDateString()}
</p>
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
{/* Client Components for interactivity */}
<LikeButton postId={post.id} initialCount={post._count.likes} />
<CommentSection postId={post.id} />
</article>
);
}The page itself (header, content, metadata) renders on the server with zero JS. Only the LikeButton and CommentSection ship JavaScript to the browser.
Server Actions: Mutations Without API Routes
Server Actions let you call server-side functions directly from client components — no API route needed:
// app/actions/posts.ts
'use server';
import { db } from '@/lib/database';
import { revalidatePath } from 'next/cache';
export async function createComment(formData: FormData) {
const postId = formData.get('postId') as string;
const content = formData.get('content') as string;
await db.comments.create({
data: {
postId,
content,
authorId: await getCurrentUserId(),
},
});
revalidatePath(`/posts/${postId}`);
}// components/CommentForm.tsx
'use client';
import { createComment } from '@/app/actions/posts';
import { useActionState } from 'react';
export function CommentForm({ postId }: { postId: string }) {
const [state, action, isPending] = useActionState(createComment, null);
return (
<form action={action}>
<input type="hidden" name="postId" value={postId} />
<textarea name="content" placeholder="Write a comment..." required />
<button type="submit" disabled={isPending}>
{isPending ? 'Posting...' : 'Post Comment'}
</button>
</form>
);
}Performance Wins: Real Numbers
Here’s what a typical RSC migration delivers:
- 40-60% smaller JS bundles — server components ship zero JavaScript
- Faster Time to Interactive (TTI) — less JS to parse and execute
- No client-side waterfalls — data fetching happens on the server, close to the database
- Better SEO — fully rendered HTML on first response
- Simplified code — no more
useEffect+ loading state + error boundary boilerplate for data fetching
Common Mistakes to Avoid
1. Making Everything a Client Component
Don’t add 'use client' to every file. Start with Server Components and only opt into client when you need interactivity.
2. Passing Non-Serializable Props
Data passed from Server to Client Components must be serializable (no functions, Dates need conversion, no class instances).
// ❌ Wrong — Date object won't serialize
<ClientComp date={post.createdAt} />
// ✅ Right — Convert to string
<ClientComp date={post.createdAt.toISOString()} />3. Importing Server-Only Code in Client Components
Use the server-only package to prevent accidental client-side imports:
// lib/database.ts
import 'server-only';
import { PrismaClient } from '@prisma/client';
export const db = new PrismaClient();When NOT to Use Server Components
Server Components aren’t always the answer. Stick with Client Components when you need:
- Real-time updates (WebSocket-driven UIs)
- Heavy animations or canvas rendering
- Offline-first functionality
- Browser APIs (geolocation, camera, clipboard)
Wrapping Up
React Server Components represent the biggest architectural shift in React since hooks. The mental model is straightforward: render data on the server, handle interactions on the client. By following this principle and using the composition pattern, you’ll build faster, leaner applications with dramatically less boilerplate.
Start your next feature as a Server Component. You’ll be surprised how much code you can delete.

Leave a Reply