React Server Components (RSC) represent a fundamental shift in React architecture. It’s not just a new feature—it’s a rethinking of “where should components run?” This article takes you deep into the core concepts and practical approaches of RSC.
Why Do We Need Server Components?
Traditional React’s Dilemma
Traditional React app flow:
┌─────────────────────────────────────────────────────┐
│ 1. Browser requests page │
│ 2. Server returns HTML (mostly empty) │
│ 3. Browser downloads JavaScript bundle (can be big) │
│ 4. JavaScript executes, components render │
│ 5. Components make API requests for data │
│ 6. Data returns, re-render │
│ │
│ Problems: │
│ - Bundle keeps growing │
│ - Waterfall requests (load JS, then fetch data) │
│ - Client handles all rendering logic │
└─────────────────────────────────────────────────────┘
The Server Components Solution
React Server Components flow:
┌─────────────────────────────────────────────────────┐
│ 1. Browser requests page │
│ 2. Server runs Server Components │
│ - Direct database/API access │
│ - Renders components to special format │
│ 3. Returns rendered result + needed Client Comps │
│ 4. Browser displays content, hydrates Client Comps │
│ │
│ Benefits: │
│ - Zero bundle size (Server Comps not sent to client)│
│ - Direct data access (no API round trips) │
│ - Streaming (render and send progressively) │
└─────────────────────────────────────────────────────┘
Core Concepts
Server Components vs Client Components
// Default is Server Component
// page.tsx
async function BlogPost({ slug }: { slug: string }) {
// Direct database access!
const post = await db.posts.findUnique({ where: { slug } });
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
{/* Embed Client Component */}
<LikeButton postId={post.id} />
</article>
);
}
// Components needing interactivity are marked as Client Component
// LikeButton.tsx
'use client'; // 👈 This directive is important
import { useState } from 'react';
export function LikeButton({ postId }: { postId: string }) {
const [likes, setLikes] = useState(0);
return (
<button onClick={() => setLikes(l => l + 1)}>
❤️ {likes}
</button>
);
}
Component Type Comparison
| Feature | Server Component | Client Component |
|---|---|---|
| Runs on | Server | Browser |
| Backend access | ✅ Direct | ❌ Needs API |
| Can use hooks | ❌ | ✅ |
| Can add event listeners | ❌ | ✅ |
| Can use browser APIs | ❌ | ✅ |
| Bundle size | 0 KB | Included in bundle |
| Can async/await | ✅ | ❌ (needs use) |
Data Fetching Patterns
Data Fetching in Server Components
// ✅ Direct async/await
async function UserProfile({ userId }: { userId: string }) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: { posts: true }
});
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<PostList posts={user.posts} />
</div>
);
}
Parallel Data Fetching
async function Dashboard() {
// Fetch multiple data sources in parallel
const [user, posts, analytics] = await Promise.all([
getUser(),
getPosts(),
getAnalytics()
]);
return (
<div>
<UserCard user={user} />
<PostList posts={posts} />
<AnalyticsChart data={analytics} />
</div>
);
}
Streaming with Suspense
import { Suspense } from 'react';
async function Page() {
return (
<div>
{/* This part shows immediately */}
<Header />
{/* This part streams in */}
<Suspense fallback={<PostsSkeleton />}>
<Posts />
</Suspense>
{/* This can also load independently */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</div>
);
}
async function Posts() {
// This request doesn't block page display
const posts = await fetchPosts();
return <PostList posts={posts} />;
}
Component Composition Patterns
Server Component Nested in Client Component
// ✅ Server Component can pass Server Component as props
// Layout.tsx (Server Component)
import { Sidebar } from './Sidebar';
import { InteractivePanel } from './InteractivePanel';
async function Layout() {
const data = await fetchData();
return (
<div>
{/* Client Component */}
<InteractivePanel>
{/* Server Component passed as children */}
<Sidebar data={data} />
</InteractivePanel>
</div>
);
}
// InteractivePanel.tsx
'use client';
import { useState, ReactNode } from 'react';
export function InteractivePanel({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(true);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
Toggle
</button>
{isOpen && children}
</div>
);
}
Patterns to Avoid
// ❌ Client Component cannot directly import Server Component
'use client';
import { ServerComponent } from './ServerComponent'; // ❌ This will error
export function ClientComponent() {
return <ServerComponent />; // ❌
}
// ✅ Correct way: pass through props
'use client';
export function ClientComponent({ children }: { children: ReactNode }) {
return <div>{children}</div>; // ✅ children can be Server Component
}
Server Actions
Server Actions let you define server-side functions directly in components:
// actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await prisma.post.create({
data: { title, content }
});
revalidatePath('/posts');
redirect('/posts');
}
export async function likePost(postId: string) {
await prisma.post.update({
where: { id: postId },
data: { likes: { increment: 1 } }
});
revalidatePath(`/posts/${postId}`);
}
Using in Forms
// CreatePostForm.tsx (can be Server Component!)
import { createPost } from './actions';
export function CreatePostForm() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Publish</button>
</form>
);
}
Using in Client Components
// LikeButton.tsx
'use client';
import { useTransition } from 'react';
import { likePost } from './actions';
export function LikeButton({ postId }: { postId: string }) {
const [isPending, startTransition] = useTransition();
return (
<button
disabled={isPending}
onClick={() => {
startTransition(() => {
likePost(postId);
});
}}
>
{isPending ? '❤️...' : '❤️ Like'}
</button>
);
}
Caching Strategies
Request Deduplication
// React automatically deduplicates identical fetch requests
async function Component1() {
const data = await fetch('/api/data'); // Request 1
return <div>{data}</div>;
}
async function Component2() {
const data = await fetch('/api/data'); // Automatically reuses Request 1
return <div>{data}</div>;
}
Cache Control
// Next.js extended fetch options
// Default caching
const data = await fetch('https://api.example.com/data');
// No caching
const data = await fetch('https://api.example.com/data', {
cache: 'no-store'
});
// Time-based revalidation
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 } // Revalidate after 60 seconds
});
// Tag-based caching
const data = await fetch('https://api.example.com/data', {
next: { tags: ['posts'] }
});
// On-demand revalidation
import { revalidateTag } from 'next/cache';
revalidateTag('posts');
Practical Example: Blog Application
Directory Structure
app/
├── layout.tsx # Server Component
├── page.tsx # Server Component
├── posts/
│ ├── page.tsx # Server Component
│ ├── [slug]/
│ │ └── page.tsx # Server Component
│ └── new/
│ └── page.tsx # Server Component
├── components/
│ ├── Header.tsx # Server Component
│ ├── PostCard.tsx # Server Component
│ ├── SearchBar.tsx # 'use client'
│ └── LikeButton.tsx # 'use client'
└── actions/
└── posts.ts # 'use server'
Implementation
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { Suspense } from 'react';
import { LikeButton } from '@/components/LikeButton';
import { Comments } from '@/components/Comments';
interface Props {
params: { slug: string };
}
export async function generateMetadata({ params }: Props) {
const post = await getPost(params.slug);
return { title: post?.title ?? 'Not Found' };
}
export default async function PostPage({ params }: Props) {
const post = await getPost(params.slug);
if (!post) {
notFound();
}
return (
<article className="max-w-2xl mx-auto">
<header>
<h1 className="text-4xl font-bold">{post.title}</h1>
<time className="text-gray-500">
{post.publishedAt.toLocaleDateString()}
</time>
</header>
<div className="prose mt-8">
{post.content}
</div>
<footer className="mt-8 flex items-center gap-4">
<LikeButton postId={post.id} initialLikes={post.likes} />
</footer>
<section className="mt-12">
<h2 className="text-2xl font-bold mb-4">Comments</h2>
<Suspense fallback={<div>Loading comments...</div>}>
<Comments postId={post.id} />
</Suspense>
</section>
</article>
);
}
async function getPost(slug: string) {
return prisma.post.findUnique({
where: { slug },
include: { author: true }
});
}
Performance Comparison
Traditional SPA:
┌────────────────────────────────────────────────┐
│ JavaScript Bundle: 250KB │
│ First request: HTML → JS → Data → Render │
│ Total time: ~3s │
└────────────────────────────────────────────────┘
React Server Components:
┌────────────────────────────────────────────────┐
│ JavaScript Bundle: 50KB (Client parts only) │
│ First request: Pre-rendered content directly │
│ Total time: ~0.8s │
│ │
│ Savings: 80% JS size, 70% load time │
└────────────────────────────────────────────────┘
Best Practices
1. Default to Server Components
// ✅ Recommended: Only use 'use client' when needed
// Most components should be Server Components
// ❌ Avoid: Marking everything as Client Component
2. Push Client Logic Down
// ❌ Entire page becomes Client Component
'use client';
function Page() {
const [count, setCount] = useState(0);
return (
<div>
<Header />
<Content />
<Counter count={count} setCount={setCount} />
<Footer />
</div>
);
}
// ✅ Only interactive parts are Client Components
function Page() {
return (
<div>
<Header />
<Content />
<Counter /> {/* Only this is 'use client' */}
<Footer />
</div>
);
}
3. Avoid Passing Large Objects Across Client Boundary
// ❌ Passing entire object
<ClientComponent user={user} />
// ✅ Pass only needed data
<ClientComponent userName={user.name} userAvatar={user.avatar} />
Summary
React Server Components represent React’s future direction:
| Aspect | Traditional React | Server Components |
|---|---|---|
| Render location | All client | Server + Client |
| Data fetching | useEffect/SWR | Direct async/await |
| Bundle size | All code | Only interactive code |
| Initial performance | Slower | Very fast |
Key Takeaways:
- Server Components run on server by default, zero client cost
- Only use
'use client'for components needing interactivity - Server Actions provide type-safe server functions
- Suspense enables streaming for better UX
- Proper component boundary design is key to performance
RSC isn’t about replacing Client Components—it’s about using the right component type in the right place.
Server-first, client-enhanced—this is modern React’s philosophy.