React Server Components and Client Components serve different purposes. Choosing where to draw the boundary affects bundle size, data fetching patterns, and user experience. This guide helps you make that choice effectively.
The mental model
Think of your app as a tree where each component is either Server or Client:
Page (Server)
├── Header (Server)
│ └── NavMenu (Client) ← Needs onClick
├── Content (Server)
│ ├── Article (Server) ← Static content
│ └── Comments (Server)
│ └── CommentForm (Client) ← Needs useState
└── Footer (Server)
Server Components are the default. The "use client" directive creates a boundary—everything below it (including imports) becomes client code.
Quick reference
| Need | Component type | Why |
|---|---|---|
| Fetch data | Server | Direct DB/API access |
| Static UI | Server | No JS shipped |
| onClick handler | Client | Browser event |
| useState/useEffect | Client | React hooks |
| Browser APIs | Client | No window on server |
| Third-party with hooks | Client | Depends on Client |
Default to Server
Start with everything as Server Components:
// app/page.tsx - Server Component by default
async function Page() {
const posts = await db.posts.findMany();
return (
<main>
<h1>Blog</h1>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</main>
);
}
No "use client", no loading states, no client-side fetch. Data loads on the server and renders to HTML.
Add Client boundaries for interactivity
When you need interactivity, create a small Client Component:
// components/LikeButton.tsx
'use client';
import { useState } from 'react';
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
);
}
Use it in a Server Component:
// app/posts/[id]/page.tsx - Server Component
import { LikeButton } from '@/components/LikeButton';
async function PostPage({ params }: { params: { id: string } }) {
const post = await getPost(params.id);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<LikeButton postId={post.id} /> {/* Client island */}
</article>
);
}
The article is Server-rendered. Only the button ships JavaScript.
The composition pattern
Pass Server Component output to Client Components as children:
// app/page.tsx - Server Component
import { Tabs } from './Tabs';
import { ProductList } from './ProductList';
async function Page() {
const products = await getProducts();
return (
<Tabs>
<ProductList products={products} /> {/* Server-rendered */}
</Tabs>
);
}
// components/Tabs.tsx - Client Component
'use client';
import { useState } from 'react';
export function Tabs({ children }: { children: React.ReactNode }) {
const [activeTab, setActiveTab] = useState(0);
return (
<div>
<div className="tabs">
<button onClick={() => setActiveTab(0)}>All</button>
<button onClick={() => setActiveTab(1)}>Featured</button>
</div>
{children}
</div>
);
}
The ProductList renders on the server. The Tabs wrapper handles interactivity. Children are pre-rendered.
Common mistakes
Putting 'use client' too high
// DON'T: Makes everything client
'use client';
export function Page() {
const [data, setData] = useState([]); // Now you need useEffect to fetch
// ...
}
// DO: Keep page as Server, wrap only interactive parts
export async function Page() {
const data = await fetchData(); // Server fetch
return <InteractiveWrapper data={data} />;
}
Importing Server into Client
// DON'T: This doesn't work
'use client';
import { ServerComponent } from './ServerComponent'; // Error!
export function ClientWrapper() {
return <ServerComponent />; // Can't render Server in Client
}
// DO: Pass as children
'use client';
export function ClientWrapper({ children }: { children: React.ReactNode }) {
return <div onClick={handleClick}>{children}</div>;
}
// Usage in Server Component
<ClientWrapper>
<ServerComponent />
</ClientWrapper>
Unnecessary Client Components
// DON'T: Static content doesn't need client
'use client';
export function Footer() {
return (
<footer>
<p>© 2026 My Company</p>
<nav>...</nav>
</footer>
);
}
// DO: Remove 'use client' for static components
export function Footer() {
return (
<footer>
<p>© 2026 My Company</p>
<nav>...</nav>
</footer>
);
}
Decision flowchart
Does it need hooks (useState, useEffect)?
Yes → Client Component
No ↓
Does it handle events (onClick, onChange)?
Yes → Client Component
No ↓
Does it use browser APIs (window, localStorage)?
Yes → Client Component
No ↓
Does it import a library that needs Client?
Yes → Client Component
No → Server Component (default)
Mixed component strategies
Strategy 1: Interactive wrapper
Server fetches data, Client wraps for interactivity:
// Server Component
async function Page() {
const items = await getItems();
return (
<DraggableList> {/* Client: handles drag */}
{items.map(item => (
<Item key={item.id} {...item} /> {/* Server: rendered HTML */}
))}
</DraggableList>
);
}
Strategy 2: Slots
Pass multiple Server Components as props:
// Server Component
function Layout() {
return (
<ClientShell
header={<Header />} {/* Server */}
sidebar={<Sidebar />} {/* Server */}
content={<MainContent />} {/* Server */}
/>
);
}
// Client Component
'use client';
function ClientShell({ header, sidebar, content }) {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<div>
{header}
{sidebarOpen && sidebar}
{content}
</div>
);
}
Strategy 3: Lift state up
Move Client boundary up when children need shared state:
// If Filter and ProductList need to share state,
// wrap both in a Client Component
'use client';
function ProductPage({ initialProducts }) {
const [filter, setFilter] = useState('');
const filtered = initialProducts.filter(/* ... */);
return (
<>
<Filter value={filter} onChange={setFilter} />
<ProductList products={filtered} />
</>
);
}
// Fetch in Server parent
async function Page() {
const products = await getProducts();
return <ProductPage initialProducts={products} />;
}
Bundle impact
Every Client Component adds to the JavaScript bundle:
| Component type | JS sent to client |
|---|---|
| Server | 0 KB |
| Small Client (button) | ~1-2 KB |
| Form with validation | ~5-10 KB |
| Rich text editor | ~50+ KB |
Audit with Next.js bundle analyzer:
npm install @next/bundle-analyzer
Summary
Default to Server Components for data fetching and static UI. Add "use client" only for interactivity—hooks, events, browser APIs. Keep Client boundaries as low as possible in the component tree. Use the children pattern to pass Server Component output to Client Components. The goal is minimal JavaScript with maximum functionality.
