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
- Understanding React Components
- Server Components Explained
- Client Components Explained
- Key Differences
- When to Use Server Components
- When to Use Client Components
- Performance Comparison
- Common Patterns and Examples
- Migration Strategies
- Best Practices
- Common Pitfalls to Avoid
- Conclusion
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:
- Server Components (default): Render on the server, no JavaScript sent to client
- Client Components: Render on both server and client, JavaScript sent to browser
- 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 Componentimport { 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:
- Reduced Bundle Size: No JavaScript is sent to the client for Server Components
- Direct Data Access: Access databases and backend resources without API routes
- Better Security: Keep sensitive logic and credentials on the server
- Improved Performance: Faster initial page loads with less JavaScript
- 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
| Aspect | Server Components | Client Components |
|---|---|---|
| Renders on | Server only | Server (SSR) + Client (hydration) |
| JavaScript sent | None | Full component code |
| Hydration needed | No | Yes |
| Can access | Server resources | Browser APIs |
Data Fetching
Server Components can fetch data directly, while Client Components need API routes:
// Server Component - Direct data accessasync 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 bundleasync 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 eventsfunction 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
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
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 Componenttype 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
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
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 Approachasync 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):
| Metric | Server Components | Client Components |
|---|---|---|
| TTFB | Faster (direct DB access) | Slower (API roundtrip) |
| FCP | Faster (no JS needed) | Slower (wait for JS) |
| Bundle Size | Smaller | Larger |
| Hydration | Not needed | Required |
Data Fetching Performance
Server Components can fetch data more efficiently:
// Server Component - Parallel data fetchingasync 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 Componentsasync 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 Componentasync 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 Componentasync 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 Componentimport { 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 Componentasync 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 Componentasync 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:
// Before: API route + Client Componentexport 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.tsxasync 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 defaultasync 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 boundaryasync 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 propsasync 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 Componentasync 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 Clientasync 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:
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 defaultexport 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 Componentfunction 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 Clientasync 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 clientPitfall 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 fetchingasync 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
-
Use Server Components by default: They reduce bundle size, improve performance, and enable direct data access.
-
Use Client Components for interactivity: When you need hooks, browser APIs, or user interactions, opt into Client Components with
'use client'. -
Keep boundaries small: Minimize the amount of code in Client Components to reduce JavaScript bundle size.
-
Pass data as props: Server Components can pass data to Client Components, enabling efficient data flow.
-
Leverage Server Actions: Use Server Actions for mutations instead of API routes when possible.
Next Steps
- Explore Next.js 16 Server Actions documentation for advanced patterns
- Learn about React 19 Server Components for deeper understanding
- Review your existing React applications and identify Server Component candidates
- Upgrade to React 19 and Next.js 16+ to take advantage of the latest Server Component improvements
- Consider reading about state management in React to understand how Server Components affect state management strategies
- Check out common React pitfalls to avoid mistakes when migrating
Additional Resources
- Next.js 16 App Router Documentation
- React 19 Server Components Guide
- Next.js 16 Upgrade Guide for migrating to the latest version
- Web Performance Optimization Guide for understanding performance implications
- Testing Strategies for testing Server and Client Components
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.