Introduction
One of the most common questions developers face when building React applications with Next.js App Router is deceptively simple: should this component be a Server Component or a Client Component? The wrong answer can mean unnecessary JavaScript shipped to the client, broken interactivity, or server-side errors that could have been avoided entirely.
React Server Components (RSC) represent a paradigm shift in how we build React applications. By default, every component in the Next.js App Router is a Server Component—it renders on the server, sends zero JavaScript to the client, and can directly access databases, file systems, and environment variables. Client Components, marked with the 'use client' directive, run on both server and client, enabling interactivity through event handlers, hooks, and browser APIs.
The decision is not always obvious. Some components look like they should be server components but actually need client-side state. Others seem like they need client interactivity but can be restructured to work as server components. In this guide, we provide a practical, repeatable decision framework that will make these choices clear and consistent across your team.
Understanding Server Components vs Client Components: Core Concepts
What Server Components Can Do
Server Components execute exclusively on the server during the request lifecycle. They have access to the full Node.js runtime and can perform operations that would be impossible or insecure on the client.
// This is a Server Component (default in App Router)
import { db } from '@/lib/db';
import { cookies } from 'next/headers';
export default async function DashboardPage() {
const session = await getSession(cookies());
const stats = await db.analytics.aggregate({
where: { userId: session.userId },
_sum: { revenue: true, orders: true }
});
return (
<div>
<h1>Dashboard</h1>
<StatsCard label="Revenue" value={`$${stats._sum.revenue}`} />
<StatsCard label="Orders" value={stats._sum.orders} />
</div>
);
}Key capabilities of Server Components:
- Direct database queries without API endpoints
- Access to server-only environment variables (API keys, secrets)
- File system operations
- Async/await for data fetching without loading states
- Zero client-side JavaScript contribution
- Can import and render Client Components as children
What Client Components Can Do
Client Components run on both server (for SSR) and client (for interactivity). They are required whenever you need browser APIs or React state.
'use client';
import { useState, useEffect } from 'react';
export function LiveChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
useEffect(() => {
const ws = new WebSocket('wss://chat.example.com');
ws.onmessage = (e) => {
setMessages(prev => [...prev, JSON.parse(e.data)]);
};
return () => ws.close();
}, []);
return (
<div>
{messages.map((msg, i) => <div key={i}>{msg.text}</div>)>
<input value={input} onChange={e => setInput(e.target.value)} />
<button onClick={() => sendMessage(input)}>Send</button>
</div>
);
}Key capabilities of Client Components:
- Event handlers (onClick, onChange, onSubmit)
- React hooks (useState, useEffect, useRef, useContext)
- Browser APIs (localStorage, navigator, WebSocket)
- Third-party libraries that use hooks or browser APIs
- Class components and legacy patterns
The Boundary Rule
The most important concept is the boundary rule: when you add 'use client' to a component, that component and everything it imports becomes part of the client bundle. This means if a Client Component imports a heavy library, that library ships to the browser even if only one small feature is used on the client.
'use client';
// This ENTIRE file and ALL its imports become client-side JavaScript
import { heavyChartingLibrary } from 'chart-lib'; // Ships to client
import { formatCurrency } from '@/lib/utils'; // Also ships to client
import { Button } from '@/components/ui/button'; // Also ships to client
export function Chart({ data }: { data: DataPoint[] }) {
// Only this render function actually needs the client
return <heavyChartingLibrary.Component data={data} />;
}This is why pushing the client boundary as deep as possible is critical.
Architecture and Design Patterns
The Decision Framework
Use this decision tree for every component you create:
Step 1: Does it need interactivity?
- Event handlers (onClick, onChange, onSubmit) → Client Component
- React hooks (useState, useEffect, useRef) → Client Component
- Browser APIs (window, document, localStorage) → Client Component
- None of the above → Go to Step 2
Step 2: Does it need server-side data?
- Database queries → Server Component
- File system access → Server Component
- API calls with secrets → Server Component
- External API calls → Server Component (avoids exposing URLs)
- No data needed → Go to Step 3
Step 3: Default to Server Component
- If it only renders UI based on props → Server Component
- If it has heavy dependencies → Server Component (keeps them off the client)
- When in doubt → Server Component
The Composition Pattern
The most effective pattern is a server component parent that fetches data and passes it to client component children for interactivity.
// Server Component: Data fetching layer
import { db } from '@/lib/db';
import { TaskList } from './task-list';
export default async function TasksPage() {
const tasks = await db.task.findMany({
where: { status: 'active' },
orderBy: { createdAt: 'desc' }
});
// Client Component: Interactive layer
return <TaskList initialTasks={tasks} />;
}'use client';
// Client Component: Interactive task management
import { useState } from 'react';
import type { Task } from '@prisma/client';
export function TaskList({ initialTasks }: { initialTasks: Task[] }) {
const [tasks, setTasks] = useState(initialTasks);
const [filter, setFilter] = useState<'all' | 'active' | 'done'>('all');
const filtered = tasks.filter(t => {
if (filter === 'all') return true;
if (filter === 'active') return t.status === 'active';
return t.status === 'done';
});
async function toggleTask(id: string) {
const res = await fetch(`/api/tasks/${id}/toggle`, { method: 'POST' });
const updated = await res.json();
setTasks(prev => prev.map(t => t.id === id ? updated : t));
}
return (
<div>
<div className="flex gap-2 mb-4">
{(['all', 'active', 'done'] as const).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={filter === f ? 'bg-blue-600 text-white' : 'bg-gray-200'}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{filtered.map(task => (
<div key={task.id} onClick={() => toggleTask(task.id)}>
<span className={task.status === 'done' ? 'line-through' : ''}>
{task.title}
</span>
</div>
))}
</div>
);
}The Wrapper Pattern
When you need interactivity in only part of a mostly-static component, extract the interactive piece into its own client component.
// Server Component: Product card layout
export function ProductCard({ product }: { product: Product }) {
return (
<div className="border rounded-lg p-4">
<img src={product.imageUrl} alt={product.name} />
<h3 className="font-bold">{product.name}</h3>
<p className="text-gray-600">{product.description}</p>
<p className="text-xl font-bold">${product.price}</p>
{/* Only this button needs to be a client component */}
<AddToCartButton productId={product.id} />
</div>
);
}'use client';
export function AddToCartButton({ productId }: { productId: string }) {
const [loading, setLoading] = useState(false);
return (
<button
onClick={async () => {
setLoading(true);
await addToCart(productId);
setLoading(false);
}}
disabled={loading}
>
{loading ? 'Adding...' : 'Add to Cart'}
</button>
);
}Step-by-Step Implementation
Implementing the Decision Framework in Practice
Let's walk through a real application and classify every component.
Blog Post Page:
// app/blog/[slug]/page.tsx — SERVER COMPONENT
// Why: Fetches data from CMS, no interactivity needed
import { getPost } from '@/lib/cms';
import { MarkdownRenderer } from './markdown-renderer';
import { ShareButtons } from './share-buttons';
import { CommentSection } from './comment-section';
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return (
<article>
<h1>{post.title}</h1>
<time>{post.publishedAt.toLocaleDateString()}</time>
<MarkdownRenderer content={post.content} />
<ShareButtons url={`/blog/${params.slug}`} title={post.title} />
<CommentSection postId={post.id} />
</article>
);
}// app/blog/[slug]/markdown-renderer.tsx — SERVER COMPONENT
// Why: Renders markdown to HTML, no interactivity needed
import { marked } from 'marked';
export function MarkdownRenderer({ content }: { content: string }) {
const html = marked(content);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}// app/blog/[slug]/share-buttons.tsx — CLIENT COMPONENT
// Why: Uses navigator.share API and click handlers
'use client';
export function ShareButtons({ url, title }: { url: string; title: string }) {
async function handleShare() {
if (navigator.share) {
await navigator.share({ title, url: window.location.origin + url });
} else {
await navigator.clipboard.writeText(window.location.origin + url);
alert('Link copied!');
}
}
return (
<div className="flex gap-2">
<button onClick={handleShare}>Share</button>
<a href={`https://twitter.com/intent/tweet?url=${url}&text=${title}`}
target="_blank" rel="noopener">
Twitter
</a>
</div>
);
}// app/blog/[slug]/comment-section.tsx — CLIENT COMPONENT
// Why: Uses useState, useEffect, and form submission
'use client';
import { useState, useEffect } from 'react';
export function CommentSection({ postId }: { postId: string }) {
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState('');
useEffect(() => {
fetch(`/api/posts/${postId}/comments`)
.then(r => r.json())
.then(setComments);
}, [postId]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const res = await fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
body: JSON.stringify({ content: newComment })
});
const comment = await res.json();
setComments(prev => [...prev, comment]);
setNewComment('');
}
return (
<div>
<h2>Comments ({comments.length})</h2>
{comments.map(c => (
<div key={c.id}>
<strong>{c.author}</strong>
<p>{c.content}</p>
</div>
))}
<form onSubmit={handleSubmit}>
<textarea
value={newComment}
onChange={e => setNewComment(e.target.value)}
placeholder="Add a comment..."
/>
<button type="submit">Post Comment</button>
</form>
</div>
);
}Handling Common Edge Cases
Edge Case: Component needs both data fetching and interactivity
// Solution: Split into server parent + client child
// notifications.tsx (Server Component)
import { db } from '@/lib/db';
import { NotificationBell } from './notification-bell';
export async function Notifications() {
const notifications = await db.notification.findMany({
where: { read: false },
take: 10
});
return <NotificationBell initialNotifications={notifications} />;
}// notification-bell.tsx (Client Component)
'use client';
export function NotificationBell({ initialNotifications }: Props) {
const [notifications, setNotifications] = useState(initialNotifications);
const [open, setOpen] = useState(false);
// Real-time updates
useEffect(() => {
const es = new EventSource('/api/notifications/stream');
es.onmessage = (e) => {
setNotifications(prev => [JSON.parse(e.data), ...prev]);
};
return () => es.close();
}, []);
return (
<div>
<button onClick={() => setOpen(!open)}>
Notifications ({notifications.length})
</button>
{open && notifications.map(n => <div key={n.id}>{n.message}</div>)}
</div>
);
}Real-World Use Cases and Case Studies
Use Case 1: E-Commerce Product Listing
A product listing page renders 50 product cards. Each card shows an image, name, price, and an "Add to Cart" button. Without the decision framework, developers often make the entire page a Client Component, shipping 150KB of unnecessary JavaScript.
The optimized approach: The page and product card layout are server components. Only the "Add to Cart" button is a client component. This reduces client JavaScript from 150KB to 12KB—a 92% reduction.
Use Case 2: Admin Dashboard
An admin dashboard has charts, tables, filters, and real-time metrics. The naive approach makes everything a client component. The optimized approach separates concerns: data fetching and layout are server components, while charts (using a charting library), filter dropdowns, and real-time metric displays are client components.
Use Case 3: Form-Heavy Application
A settings page has 20 form fields, validation, and submission logic. The form itself must be a client component because of onChange handlers and form state. But the page layout, navigation sidebar, and field labels can all be server components, reducing the initial JavaScript payload.
Best Practices for Production
-
Default to Server Components: Start every component as a server component. Only add
'use client'when you have a specific, identified need for client interactivity. -
Push boundaries down: Instead of marking a parent component as
'use client', extract the interactive piece into its own client component and keep the parent as a server component. -
Pass serializable props across boundaries: Server components can pass data to client components via props, but only serializable data (no functions, no class instances, no Dates without conversion).
-
Avoid prop drilling through client components: If a deeply nested component needs data from a server component ancestor, consider using React Context in a client component wrapper rather than threading props through multiple client components.
-
Use server-side data transformations: Do heavy data processing (sorting, filtering, formatting) in server components before passing data to client components. This keeps computation off the client.
-
Prefer server-side redirects and not-found handling: Use
redirect()andnotFound()fromnext/navigationin server components rather than client-side routing logic. -
Cache server component renders: Use
unstable_cacheor route segment config options to cache server component output for expensive queries. -
Monitor client bundle size: Use
@next/bundle-analyzerto track how much JavaScript each client component adds. Set a budget and alert when it is exceeded.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Marking a layout as 'use client' | All child pages become client components | Keep layouts as server components; extract interactivity |
| Using hooks in server components | Build error | Move the component to client or restructure without hooks |
| Passing functions as props to client components | Serialization error | Pass identifiers and define handlers in the client component |
| Importing client-only libraries in server components | Build or runtime error | Create a client component wrapper for the library |
| Fetching data in client components when server suffices | Unnecessary API endpoints, waterfalls | Move data fetching to the server component parent |
| Using 'use client' at the root | Entire app ships as client JavaScript | Remove root-level directive; apply it at the component level |
Performance Optimization
// BAD: Entire list is a client component
'use client';
export function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('/api/products').then(r => r.json()).then(setProducts);
}, []);
return products.map(p => <ProductCard key={p.id} product={p} />);
}
// GOOD: Only interactive parts are client components
// ProductList.tsx (Server Component)
export async function ProductList() {
const products = await db.product.findMany();
return (
<div>
{products.map(p => (
<div key={p.id}>
<h3>{p.name}</h3>
<p>${p.price}</p>
<AddToCartButton productId={p.id} />
</div>
))}
</div>
);
}Measurable impact: A product page with 50 items shipped 180KB as a full client component. By restructuring, the client bundle dropped to 15KB—a 92% reduction. Time to Interactive improved from 4.1s to 1.3s on a 4G connection.
Comparison with Alternatives
| Approach | Bundle Size | Data Fetching | Interactivity | Complexity |
|---|---|---|---|---|
| All Server Components | Minimal | Direct DB access | None (static) | Low |
| All Client Components | Full bundle | API endpoints required | Full | Low |
| Mixed (Recommended) | Optimal | Direct for server, API for client | Full where needed | Moderate |
| ISR/SSG | Minimal | Build/revalidation time | None (static) | Low |
| SSR + Hydration | Full bundle | Server-side | Full after hydration | Moderate |
Testing Strategies
// Test server component rendering
import { renderToString } from 'react-dom/server';
test('Dashboard renders stats from server data', async () => {
const html = await renderToString(<DashboardPage />);
expect(html).toContain('$1,234');
expect(html).toContain('42 orders');
});
// Test client component interactivity
import { render, screen, fireEvent } from '@testing-library/react';
test('AddToCartButton handles click', async () => {
render(<AddToCartButton productId="123" />);
fireEvent.click(screen.getByText('Add to Cart'));
expect(screen.getByText('Adding...')).toBeInTheDocument();
});Future Outlook
React's server component model is still evolving. The React team is working on improving the developer experience around the server-client boundary, including better error messages when you accidentally use client features in server components and more granular control over what gets shipped to the client.
Server Actions (previously Server Functions) are further blurring the line by allowing client components to call server-side functions directly. This reduces the need for API endpoints even for mutations, while keeping the interactive benefits of client components.
The long-term vision is a world where developers rarely think about the server-client boundary—the framework and compiler determine the optimal placement automatically based on the code's requirements.
Conclusion
The decision between Server and Client Components comes down to a simple question: does this component need browser interactivity? If yes, it is a Client Component. If no, it is a Server Component.
Key takeaways:
- Default to Server Components—they are the foundation of performant React apps
- Use
'use client'only when you need event handlers, hooks, or browser APIs - Push the client boundary as deep as possible in your component tree
- Use the composition pattern: server parent fetches data, client children handle interactivity
- Monitor your client bundle size to catch accidental client boundary misplacement
This framework transforms the server vs client decision from an art into a science. Apply it consistently across your team, and you will ship faster, lighter, and more performant React applications.