Skip to main content

React Server Components vs Client Components: Complete Guide

Master React Server Components and Client Components with practical examples, performance comparisons, and best practices for Next.js 16+ and React 19 applications.

Table of Contents

Introduction

React Server Components represent one of the most significant shifts in React architecture since the introduction of hooks. Introduced in React 18 and fully integrated into Next.js 13+ App Router (now stable in Next.js 16+), Server Components fundamentally change how we think about component rendering, data fetching, and application performance. With React 19 and Next.js 16, Server Components have become production-ready with enhanced performance, improved caching, and better developer experience.

The traditional React model has always been client-side: components render in the browser, state lives in JavaScript memory, and interactions happen through event handlers. Server Components flip this paradigm by allowing components to render on the server, reducing JavaScript bundle size, improving initial load times, and enabling direct database access without API routes.

However, this new architecture introduces complexity. Understanding when to use Server Components versus Client Components is crucial for building performant, maintainable applications. The wrong choice can lead to unnecessary JavaScript bundles, poor user experience, or even broken functionality.

This comprehensive guide will help you master React Server Components and Client Components. You’ll learn the fundamental differences, see practical examples, understand performance implications, and discover best practices for making the right architectural decisions. Whether you’re building a new Next.js application or migrating an existing React app, this guide will give you the knowledge needed to leverage Server Components effectively.

By the end of this guide, you’ll understand how to structure your components for optimal performance, know when to use each type, and be able to implement common patterns correctly.


Understanding React Components

Before diving into Server Components, it’s essential to understand the evolution of React’s component model and how Server Components fit into the broader ecosystem.

The Traditional React Model

In traditional React applications, all components are client components by default. They run in the browser, manage state with hooks like useState and useEffect, and handle user interactions:

// Traditional client component
'use client'; // Explicit in Next.js 13+ (required in Next.js 16+), implicit in older React
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}

This model works well for interactive applications, but it has limitations:

  • Large JavaScript bundles: All component code must be sent to the client
  • Client-side data fetching: Requires API routes or external endpoints
  • Hydration overhead: Server-rendered HTML must be “hydrated” with JavaScript
  • SEO challenges: Content rendered client-side may not be immediately available to crawlers

The Server Components Revolution

Server Components address these limitations by allowing components to render on the server. They’re not a replacement for Client Components but rather a complementary technology that works alongside them.

⚠️ Important: Server Components are a React 19 feature, but they’re primarily used through frameworks like Next.js 16+ App Router. The concepts apply broadly, but implementation details vary by framework. Next.js 16+ includes Turbopack as the default bundler and enhanced Server Component support with improved caching and performance.

Component Types Overview

In Next.js 16+ App Router, there are three types of components:

  1. Server Components (default): Render on the server, no JavaScript sent to client
  2. Client Components: Render on both server and client, JavaScript sent to browser
  3. Shared Components: Can be used in both contexts with appropriate directives

Understanding these distinctions is crucial for building efficient applications.

React 19 and Next.js 16 Enhancements

React 19 and Next.js 16 bring significant improvements to Server Components:

  • React 19: Enhanced Server Component support with improved streaming, better error boundaries, and optimized re-rendering
  • Next.js 16: Turbopack as default bundler (faster builds), improved caching APIs (updateTag(), revalidateTag()), and enhanced Server Component performance
  • Better TypeScript Support: Improved type safety for Server and Client Component boundaries
  • Enhanced Developer Experience: Better error messages and debugging tools for Server Components

💡 Tip: Next.js 16 includes automatic memoization through the React Compiler, reducing the need for manual useMemo and useCallback optimizations in Client Components.


Server Components Explained

Server Components are React components that render exclusively on the server. They never ship JavaScript to the client, which means they can’t use browser APIs, hooks, or event handlers.

What Makes a Server Component?

In Next.js 16+ App Router, components are Server Components by default. You don’t need any special directive:

// app/products/page.tsx - Server Component (default)
async function ProductsPage() {
// Direct database access - no API route needed!
const products = await db.products.findMany();
return (
<div>
<h1>Products</h1>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}

Key Characteristics of Server Components

Server Components have several important characteristics:

Can do:

  • Direct database access
  • Access backend resources (file system, environment variables)
  • Fetch data without exposing API endpoints
  • Keep sensitive logic on the server
  • Reduce JavaScript bundle size
  • Improve SEO with server-rendered content

Cannot do:

  • Use React hooks (useState, useEffect, useContext, etc.)
  • Use browser APIs (window, document, localStorage, etc.)
  • Handle user interactions (onClick, onChange, etc.)
  • Use event listeners
  • Maintain client-side state

Server Component Example

Here’s a practical example showing Server Components fetching data directly:

// app/blog/[slug]/page.tsx - Server Component
import { db } from '@/lib/db';
import { notFound } from 'next/navigation';
type Props = {
params: { slug: string };
};
async function BlogPost({ params }: Props) {
// Direct database query - runs on server
const post = await db.post.findUnique({
where: { slug: params.slug },
include: { author: true, comments: true }
});
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author.name}</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
{/* Server Component can render other Server Components */}
<CommentsList comments={post.comments} />
</article>
);
}
export default BlogPost;

🔍 Deep Dive: Server Components can be async functions, allowing you to use await directly in the component body. This is one of their most powerful features—you can fetch data without creating separate API routes.

Server Component Benefits

The primary benefits of Server Components include:

  1. Reduced Bundle Size: No JavaScript is sent to the client for Server Components
  2. Direct Data Access: Access databases and backend resources without API routes
  3. Better Security: Keep sensitive logic and credentials on the server
  4. Improved Performance: Faster initial page loads with less JavaScript
  5. SEO Benefits: Content is fully rendered on the server

Client Components Explained

Client Components are the traditional React components you’re familiar with. They render on both the server (for initial HTML) and the client (for interactivity), and their JavaScript is sent to the browser.

What Makes a Client Component?

In Next.js 16+ App Router, you mark a component as a Client Component using the 'use client' directive:

'use client'; // This directive makes it a Client Component
import { useState } from 'react';
function InteractiveCounter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}

Key Characteristics of Client Components

Client Components have full access to React’s interactive features:

Can do:

  • Use all React hooks (useState, useEffect, useContext, etc.)
  • Handle user interactions (events, forms, etc.)
  • Use browser APIs (window, document, localStorage, etc.)
  • Maintain client-side state
  • Use third-party libraries that depend on browser APIs
  • Create interactive UI elements

Cannot do:

  • Direct database access (must use API routes or Server Actions)
  • Access server-only resources directly
  • Use server-only modules

Client Component Example

Here’s a practical example showing Client Components handling interactivity:

'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
function SearchBar() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const router = useRouter();
useEffect(() => {
// Client-side search with debouncing
const timer = setTimeout(() => {
if (query.length > 2) {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data));
}
}, 300);
return () => clearTimeout(timer);
}, [query]);
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{results.length > 0 && (
<ul>
{results.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
)}
</div>
);
}

💡 Tip: The 'use client' directive creates a “boundary” in your component tree. All components imported into a Client Component become Client Components as well, even if they don’t have the directive.


Key Differences

Understanding the differences between Server and Client Components is crucial for making the right architectural decisions.

Rendering Location

AspectServer ComponentsClient Components
Renders onServer onlyServer (SSR) + Client (hydration)
JavaScript sentNoneFull component code
Hydration neededNoYes
Can accessServer resourcesBrowser APIs

Data Fetching

Server Components can fetch data directly, while Client Components need API routes:

// Server Component - Direct data access
async function ServerProductList() {
const products = await db.products.findMany();
return <ProductList products={products} />;
}
// Client Component - Needs API route
'use client';
function ClientProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('/api/products')
.then(res => res.json())
.then(data => setProducts(data));
}, []);
return <ProductList products={products} />;
}

Bundle Size Impact

Server Components don’t contribute to your JavaScript bundle:

// Server Component - 0KB added to bundle
async function HeavyDataProcessor() {
const data = await processLargeDataset(); // Runs on server
return <div>{/* Complex rendering */}</div>;
}
// Client Component - Full code in bundle
'use client';
function HeavyDataProcessor() {
const [data, setData] = useState(null);
// All this code is sent to the client
useEffect(() => {
const processed = processLargeDataset(); // Runs on client!
setData(processed);
}, []);
return <div>{/* Complex rendering */}</div>;
}

State and Interactivity

Only Client Components can handle user interactions:

// ❌ Server Component - Cannot handle events
function ServerButton() {
return (
<button onClick={() => alert('Clicked')}>
Click me
</button>
); // This won't work - no JavaScript on client!
}
// ✅ Client Component - Can handle events
'use client';
function ClientButton() {
return (
<button onClick={() => alert('Clicked')}>
Click me
</button>
); // Works perfectly!
}

When to Use Server Components

Server Components are ideal for most of your application’s UI. Use them by default and only opt into Client Components when you need interactivity.

✅ Use Server Components For:

1. Data Fetching and Display

app/dashboard/page.tsx
async function Dashboard() {
// Fetch multiple data sources in parallel
const [users, posts, analytics] = await Promise.all([
db.users.findMany(),
db.posts.findMany(),
getAnalytics()
]);
return (
<div>
<UserList users={users} />
<PostList posts={posts} />
<Analytics data={analytics} />
</div>
);
}

2. Static or Server-Rendered Content

app/about/page.tsx
function AboutPage() {
return (
<div>
<h1>About Us</h1>
<p>Static content that doesn't need interactivity</p>
<TeamMembers /> {/* Can be Server Component */}
</div>
);
}

3. Components That Don’t Need Interactivity

// components/ProductCard.tsx - Server Component
type ProductCardProps = {
product: {
id: string;
name: string;
price: number;
image: string;
};
};
function ProductCard({ product }: ProductCardProps) {
return (
<div>
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
{/* No interactivity needed - perfect for Server Component */}
</div>
);
}

4. SEO-Critical Content

app/blog/[slug]/page.tsx
async function BlogPost({ params }: Props) {
const post = await getPost(params.slug);
return (
<>
{/* Fully server-rendered for SEO */}
<h1>{post.title}</h1>
<article>{post.content}</article>
<Metadata
title={post.title}
description={post.excerpt}
/>
</>
);
}

5. Accessing Backend Resources

app/files/page.tsx
import fs from 'fs';
import path from 'path';
async function FilesList() {
// Direct file system access - only possible in Server Component
const files = fs.readdirSync('./uploads');
return (
<ul>
{files.map(file => (
<li key={file}>{file}</li>
))}
</ul>
);
}

When to Use Client Components

Client Components are necessary when you need interactivity, browser APIs, or client-side state management.

✅ Use Client Components For:

1. User Interactions

'use client';
import { useState } from 'react';
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Handle login logic
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Login</button>
</form>
);
}

2. Browser APIs

'use client';
import { useEffect, useState } from 'react';
function WindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const updateSize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', updateSize);
updateSize();
return () => window.removeEventListener('resize', updateSize);
}, []);
return <p>Window: {size.width} x {size.height}</p>;
}

3. Third-Party Libraries Requiring Browser APIs

'use client';
import { useEffect, useRef } from 'react';
import Chart from 'chart.js/auto';
function AnalyticsChart({ data }: { data: number[] }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!canvasRef.current) return;
const chart = new Chart(canvasRef.current, {
type: 'line',
data: { datasets: [{ data }] }
});
return () => chart.destroy();
}, [data]);
return <canvas ref={canvasRef} />;
}

4. Context Providers

'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
type ThemeContextType = {
theme: 'light' | 'dark';
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}

5. Real-Time Features

'use client';
import { useEffect, useState } from 'react';
function LiveNotifications() {
const [notifications, setNotifications] = useState<string[]>([]);
useEffect(() => {
// WebSocket connection - requires browser API
const ws = new WebSocket('wss://api.example.com/notifications');
ws.onmessage = (event) => {
const notification = JSON.parse(event.data);
setNotifications(prev => [notification, ...prev]);
};
return () => ws.close();
}, []);
return (
<div>
{notifications.map((notif, i) => (
<div key={i}>{notif}</div>
))}
</div>
);
}

Performance Comparison

Understanding the performance implications of Server vs Client Components helps you make informed architectural decisions.

Bundle Size Impact

Server Components dramatically reduce JavaScript bundle size:

// Scenario: Product listing page with 100 products
// ❌ Client Component Approach
'use client';
function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('/api/products')
.then(res => res.json())
.then(setProducts);
}, []);
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
// Bundle size: ~150KB (includes React, hooks, fetch logic, ProductCard)
// ✅ Server Component Approach
async function ProductList() {
const products = await db.products.findMany();
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
// Bundle size: ~0KB for ProductList (only ProductCard if it's interactive)

Initial Load Performance

Server Components improve Time to First Byte (TTFB) and First Contentful Paint (FCP):

MetricServer ComponentsClient Components
TTFBFaster (direct DB access)Slower (API roundtrip)
FCPFaster (no JS needed)Slower (wait for JS)
Bundle SizeSmallerLarger
HydrationNot neededRequired

Data Fetching Performance

Server Components can fetch data more efficiently:

// Server Component - Parallel data fetching
async function Dashboard() {
// These run in parallel on the server
const [users, posts, analytics] = await Promise.all([
db.users.findMany(),
db.posts.findMany(),
getAnalytics()
]);
return <DashboardContent users={users} posts={posts} analytics={analytics} />;
}
// Client Component - Sequential or complex orchestration
'use client';
function Dashboard() {
const [users, setUsers] = useState([]);
const [posts, setPosts] = useState([]);
const [analytics, setAnalytics] = useState(null);
useEffect(() => {
// Multiple roundtrips to API
Promise.all([
fetch('/api/users').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/analytics').then(r => r.json())
]).then(([u, p, a]) => {
setUsers(u);
setPosts(p);
setAnalytics(a);
});
}, []);
return <DashboardContent users={users} posts={posts} analytics={analytics} />;
}

💡 Tip: Server Components eliminate the “waterfall” problem common in client-side data fetching, where one request must complete before the next can start.

Real-World Performance Example

Consider a blog post page:

// ✅ Optimized with Server Components
async function BlogPost({ params }: Props) {
// Single database query, runs on server
const post = await db.post.findUnique({
where: { slug: params.slug },
include: { author: true, comments: true }
});
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author.name}</p>
<div>{post.content}</div>
<CommentsList comments={post.comments} />
</article>
);
}
// Bundle: ~5KB (minimal JS for any interactive elements)
// ❌ Client Component approach
'use client';
function BlogPost({ params }: Props) {
const [post, setPost] = useState(null);
useEffect(() => {
fetch(`/api/posts/${params.slug}`)
.then(r => r.json())
.then(setPost);
}, [params.slug]);
if (!post) return <Loading />;
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author.name}</p>
<div>{post.content}</div>
<CommentsList comments={post.comments} />
</article>
);
}
// Bundle: ~150KB+ (React, hooks, fetch logic, loading states)

The Server Component approach:

  • ✅ Faster initial load (no JavaScript needed for content)
  • ✅ Better SEO (content in initial HTML)
  • ✅ Smaller bundle size
  • ✅ Direct database access (no API route needed)

Common Patterns and Examples

Let’s explore common patterns for combining Server and Client Components effectively.

Pattern 1: Server Component with Client Component Children

This is the most common pattern—use Server Components for data fetching and layout, Client Components for interactivity:

// app/products/page.tsx - Server Component
async function ProductsPage() {
const products = await db.products.findMany();
return (
<div>
<h1>Products</h1>
{/* Server Component renders Client Component */}
<ProductGrid products={products} />
</div>
);
}
// components/ProductGrid.tsx - Client Component
'use client';
import { useState } from 'react';
import ProductCard from './ProductCard';
type ProductGridProps = {
products: Product[];
};
function ProductGrid({ products }: ProductGridProps) {
const [filter, setFilter] = useState('');
const filtered = products.filter(p =>
p.name.toLowerCase().includes(filter.toLowerCase())
);
return (
<div>
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter products..."
/>
<div className="grid">
{filtered.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}

Pattern 2: Composing Server and Client Components

You can pass Server Component data to Client Components as props:

// app/dashboard/page.tsx - Server Component
async function Dashboard() {
const user = await getCurrentUser();
const posts = await db.posts.findMany({ where: { userId: user.id } });
return (
<div>
<UserProfile user={user} /> {/* Server Component */}
<PostList posts={posts} /> {/* Client Component receives server data */}
</div>
);
}
// components/PostList.tsx - Client Component
'use client';
import { useState } from 'react';
type PostListProps = {
posts: Post[];
};
function PostList({ posts }: PostListProps) {
const [selectedPost, setSelectedPost] = useState<Post | null>(null);
return (
<div>
{posts.map(post => (
<div
key={post.id}
onClick={() => setSelectedPost(post)}
>
{post.title}
</div>
))}
{selectedPost && <PostModal post={selectedPost} />}
</div>
);
}

Pattern 3: Server Actions with Client Components

Use Server Actions for mutations while keeping components interactive:

// app/actions.ts - Server Actions
'use server';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function createPost(title: string, content: string) {
const post = await db.post.create({
data: { title, content }
});
revalidatePath('/posts');
return post;
}
// components/CreatePostForm.tsx - Client Component
'use client';
import { createPost } from '@/app/actions';
import { useState } from 'react';
function CreatePostForm() {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
await createPost(title, content);
setTitle('');
setContent('');
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Content"
/>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}

Pattern 4: Context Providers at the Root

Place Context Providers in a Client Component at your app’s root:

// app/providers.tsx - Client Component
'use client';
import { ThemeProvider } from './components/ThemeProvider';
import { AuthProvider } from './components/AuthProvider';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
);
}
// app/layout.tsx - Server Component
import { Providers } from './providers';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
<Providers>
{children}
</Providers>
</body>
</html>
);
}

Pattern 5: Progressive Enhancement

Start with Server Components and enhance with Client Components:

// app/search/page.tsx - Server Component (works without JS)
async function SearchPage({ searchParams }: { searchParams: { q?: string } }) {
const query = searchParams.q || '';
const results = query ? await searchProducts(query) : [];
return (
<div>
<SearchForm initialQuery={query} />
<SearchResults results={results} />
</div>
);
}
// components/SearchForm.tsx - Client Component (enhances with JS)
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
function SearchForm({ initialQuery }: { initialQuery: string }) {
const router = useRouter();
const [query, setQuery] = useState(initialQuery);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
router.push(`/search?q=${encodeURIComponent(query)}`);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<button type="submit">Search</button>
</form>
);
}

Migration Strategies

If you’re migrating an existing React application to use Server Components, here’s a strategic approach.

Step 1: Identify Server Component Candidates

Look for components that:

  • Only display data (no interactivity)
  • Fetch data with useEffect
  • Don’t use hooks or browser APIs
  • Are purely presentational
// Before: Client Component
'use client';
function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('/api/products')
.then(r => r.json())
.then(setProducts);
}, []);
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
// After: Server Component
async function ProductList() {
const products = await db.products.findMany();
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}

Step 2: Extract Interactive Parts

Separate interactive logic into Client Components:

// Before: Mixed concerns
'use client';
function ProductPage() {
const [products, setProducts] = useState([]);
const [filter, setFilter] = useState('');
useEffect(() => {
fetch('/api/products')
.then(r => r.json())
.then(setProducts);
}, []);
const filtered = products.filter(p =>
p.name.includes(filter)
);
return (
<div>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{filtered.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
// After: Separated concerns
// app/products/page.tsx - Server Component
async function ProductPage() {
const products = await db.products.findMany();
return (
<div>
<ProductFilter products={products} />
</div>
);
}
// components/ProductFilter.tsx - Client Component
'use client';
function ProductFilter({ products }: { products: Product[] }) {
const [filter, setFilter] = useState('');
const filtered = products.filter(p =>
p.name.includes(filter)
);
return (
<>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{filtered.map(product => (
<ProductCard key={product.id} product={product} />
))}
</>
);
}

Step 3: Replace API Routes with Direct Data Access

Convert API routes to Server Components:

app/api/posts/route.ts
// Before: API route + Client Component
export async function GET() {
const posts = await db.posts.findMany();
return Response.json(posts);
}
// app/posts/page.tsx
'use client';
function PostsPage() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch('/api/posts')
.then(r => r.json())
.then(setPosts);
}, []);
return <PostList posts={posts} />;
}
// After: Direct Server Component
// app/posts/page.tsx
async function PostsPage() {
const posts = await db.posts.findMany();
return <PostList posts={posts} />;
}

Step 4: Handle Shared State

For state that needs to be shared, use Context Providers:

// components/ThemeProvider.tsx - Client Component
'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
const ThemeContext = createContext<{
theme: 'light' | 'dark';
toggleTheme: () => void;
} | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
return (
<ThemeContext.Provider
value={{
theme,
toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light')
}}
>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within ThemeProvider');
return context;
}

Best Practices

Follow these best practices to get the most out of Server and Client Components.

✅ Do: Use Server Components by Default

Start with Server Components and only add 'use client' when needed:

// ✅ Good: Server Component by default
async function BlogPost({ params }: Props) {
const post = await db.posts.findUnique({ where: { slug: params.slug } });
return <article>{post.content}</article>;
}
// ❌ Avoid: Unnecessary Client Component
'use client';
function BlogPost({ params }: Props) {
const [post, setPost] = useState(null);
useEffect(() => {
fetch(`/api/posts/${params.slug}`).then(r => r.json()).then(setPost);
}, [params.slug]);
if (!post) return null;
return <article>{post.content}</article>;
}

✅ Do: Keep Client Component Boundaries Small

Minimize the amount of code in Client Components:

// ✅ Good: Small Client Component boundary
async function ProductPage() {
const products = await db.products.findMany();
return (
<div>
<h1>Products</h1>
{/* Only the interactive part is a Client Component */}
<ProductFilter products={products} />
</div>
);
}
'use client';
function ProductFilter({ products }: Props) {
const [filter, setFilter] = useState('');
// Minimal Client Component code
return <input value={filter} onChange={e => setFilter(e.target.value)} />;
}
// ❌ Avoid: Large Client Component boundary
'use client';
function ProductPage() {
const [products, setProducts] = useState([]);
const [filter, setFilter] = useState('');
// Lots of Client Component code
useEffect(() => {
fetch('/api/products').then(r => r.json()).then(setProducts);
}, []);
return <div>{/* Large component tree */}</div>;
}

✅ Do: Pass Server Data as Props

Pass data from Server Components to Client Components:

// ✅ Good: Server data passed as props
async function Dashboard() {
const data = await fetchDashboardData();
return <InteractiveChart data={data} />;
}
'use client';
function InteractiveChart({ data }: { data: ChartData }) {
// Use data prop, no fetching needed
return <Chart data={data} />;
}

✅ Do: Use Server Actions for Mutations

Prefer Server Actions over API routes for mutations:

// ✅ Good: Server Action
"use server";
export async function updatePost(id: string, data: PostData) {
await db.posts.update({ where: { id }, data });
revalidatePath("/posts");
}
("use client");
function EditPostForm({ postId }: { postId: string }) {
const updatePost = useAction(updatePostAction);
// Use Server Action directly
}

❌ Don’t: Import Server-Only Code in Client Components

// ❌ Bad: Server-only import in Client Component
"use client";
import { db } from "@/lib/db"; // This will error!
function Component() {
// Can't use db here
}

❌ Don’t: Use Hooks in Server Components

// ❌ Bad: Hooks in Server Component
async function Component() {
const [state, setState] = useState(0); // Error!
useEffect(() => {}, []); // Error!
return <div>{state}</div>;
}

❌ Don’t: Pass Functions from Server to Client

// ❌ Bad: Function passed from Server to Client
async function ServerComponent() {
const handleClick = () => console.log('clicked');
return <ClientComponent onClick={handleClick} />; // Error!
}
// ✅ Good: Use Server Actions or Client Component handlers
'use server';
export async function handleClick() {
// Server Action
}
async function ServerComponent() {
return <ClientComponent action={handleClick} />;
}

💡 Tip: Use TypeScript for Safety

TypeScript helps catch Server/Client Component mistakes:

components/types.ts
export type ServerComponentProps = {
// Props that can be serialized
data: string | number | boolean | object;
};
export type ClientComponentProps = ServerComponentProps & {
// Can include functions, but only if defined in Client Component
onClick?: () => void;
};

Common Pitfalls to Avoid

Avoid these common mistakes when working with Server and Client Components.

Pitfall 1: Accidentally Making Everything a Client Component

Problem: Adding 'use client' at the root level:

// ❌ Bad: Everything becomes a Client Component
'use client'; // At the top of app/layout.tsx
export default function RootLayout({ children }) {
return <html><body>{children}</body></html>;
}

Solution: Only add 'use client' where needed:

// ✅ Good: Server Component by default
export default function RootLayout({ children }) {
return <html><body>{children}</body></html>;
}
// Only specific components are Client Components
'use client';
function InteractiveComponent() {
// Client Component code
}

Pitfall 2: Trying to Use Browser APIs in Server Components

Problem: Using window or document in Server Components:

// ❌ Bad: Browser API in Server Component
function Component() {
const width = window.innerWidth; // Error!
return <div>Width: {width}</div>;
}

Solution: Move browser API usage to Client Components:

// ✅ Good: Browser API in Client Component
'use client';
function Component() {
const [width, setWidth] = useState(0);
useEffect(() => {
setWidth(window.innerWidth);
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return <div>Width: {width}</div>;
}

Pitfall 3: Passing Non-Serializable Props

Problem: Passing functions or complex objects:

// ❌ Bad: Function passed from Server to Client
async function ServerComponent() {
const handler = () => console.log('click');
return <ClientComponent onClick={handler} />; // Error!
}

Solution: Use Server Actions or define handlers in Client Components:

// ✅ Good: Server Action
'use server';
export async function handleClick() {
// Server logic
}
async function ServerComponent() {
return <ClientComponent action={handleClick} />;
}
'use client';
function ClientComponent({ action }: { action: () => Promise<void> }) {
return <button onClick={() => action()}>Click</button>;
}

Pitfall 4: Not Understanding the Component Boundary

Problem: Thinking 'use client' only affects one file:

// ❌ Misunderstanding: All imported components become Client Components
"use client";
import { ServerComponent } from "./ServerComponent"; // This is now a Client Component!

Solution: Understand that 'use client' creates a boundary:

// ✅ Good: Keep Server Components separate
// components/ServerComponent.tsx - No 'use client'
export function ServerComponent() {
return <div>Server rendered</div>;
}
// components/ClientWrapper.tsx
'use client';
import { ServerComponent } from './ServerComponent'; // Still works, but ServerComponent code runs on client

Pitfall 5: Over-Fetching in Client Components

Problem: Fetching data that could be fetched on the server:

// ❌ Bad: Unnecessary client-side fetching
'use client';
function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('/api/products').then(r => r.json()).then(setProducts);
}, []);
return <div>{/* Render products */}</div>;
}

Solution: Fetch on the server when possible:

// ✅ Good: Server-side fetching
async function ProductList() {
const products = await db.products.findMany();
return <div>{/* Render products */}</div>;
}

Conclusion

React Server Components and Client Components represent a fundamental shift in how we build React applications. Understanding when and how to use each type is crucial for building performant, maintainable applications.

Key Takeaways

  1. Use Server Components by default: They reduce bundle size, improve performance, and enable direct data access.

  2. Use Client Components for interactivity: When you need hooks, browser APIs, or user interactions, opt into Client Components with 'use client'.

  3. Keep boundaries small: Minimize the amount of code in Client Components to reduce JavaScript bundle size.

  4. Pass data as props: Server Components can pass data to Client Components, enabling efficient data flow.

  5. Leverage Server Actions: Use Server Actions for mutations instead of API routes when possible.

Next Steps

Additional Resources

Version Requirements

This guide covers React 19 and Next.js 16+ features. To use Server Components, ensure you have:

  • React: 19.0.1 or later (19.2.1+ recommended for security patches)
  • Next.js: 16.0.0 or later (16.1.1+ recommended)
  • Node.js: 18.17 or later

⚠️ Security Note: React 19.0.0 through 19.2.0 had a critical Server Components vulnerability (CVE-2025-55182). Ensure you’re using React 19.0.1, 19.1.2, or 19.2.1+ to protect against remote code execution attacks.

Server Components aren’t just a new feature—they’re a new way of thinking about React architecture. By defaulting to Server Components and strategically using Client Components, you can build faster, more efficient applications that provide better user experiences.