React Server Components vs Client Components — When to Use Which
Advertisement
Introduction
Why This Matters
In Next.js App Router, every component is a Server Component by default. Understanding where to place the 'use client' boundary directly determines your bundle size, security posture, and performance profile. Getting this wrong causes either unnecessary JavaScript shipped to browsers or broken interactivity.
Architecture Overview
Server Components execute exclusively on the server. They can query databases directly, access environment secrets, and import large server-only packages — none of which reach the browser bundle.
Client Components execute in the browser. They support React hooks, event listeners, and Web APIs like localStorage. They are marked with 'use client' at the top of the file.
The key insight: a Server Component can render a Client Component as a child, but a Client Component cannot import and render a Server Component.
When to Use Server Components
Use Server Components for any component that fetches data, accesses environment variables, or imports large libraries not needed in the browser.
// app/products/[id]/page.tsx — Server Component (default)
async function ProductPage({ params }: { params: { id: string } }) {
// Direct database access — credentials never reach the browser
const product = await db.products.findUnique({
where: { id: params.id },
include: { reviews: true }
})
return (
<div>
<ProductDetails product={product} />
<ProductReviews reviews={product.reviews} />
{/* Client Component receives serializable props only */}
<AddToCart productId={product.id} price={product.price} />
</div>
)
}When to Use Client Components
Reserve 'use client' for components that need React state, effects, event listeners, or browser APIs.
'use client'
import { useState } from 'react'
export function AddToCart({ productId, price }: { productId: string; price: number }) {
const [quantity, setQuantity] = useState(1)
const [added, setAdded] = useState(false)
async function handleAdd() {
await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId, quantity })
})
setAdded(true)
setTimeout(() => setAdded(false), 2000)
}
return (
<div className="flex items-center gap-4">
<input
type="number"
min={1}
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
className="w-16 border rounded px-2 py-1"
/>
<span className="font-semibold">${(price * quantity).toFixed(2)}</span>
<button
onClick={handleAdd}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
{added ? 'Added!' : 'Add to Cart'}
</button>
</div>
)
}Passing Data Across the Boundary
Props crossing from Server to Client Components must be serializable — plain objects, strings, numbers, arrays. Functions, class instances, and Date objects cannot cross the boundary directly.
// app/blog/[slug]/page.tsx
async function BlogPost({ params }: { params: { slug: string } }) {
const post = await db.posts.findUnique({ where: { slug: params.slug } })
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
{/* Pass ISO string, not Date object */}
<CommentForm postId={post.id} publishedAt={post.createdAt.toISOString()} />
</article>
)
}Server Actions Pattern
Client Components can call Server Actions to mutate data without a separate API route:
// app/actions.ts
'use server'
export async function updateUserTheme(theme: 'light' | 'dark') {
const session = await getSession()
await db.users.update({
where: { id: session.userId },
data: { theme }
})
}'use client'
import { updateUserTheme } from '@/app/actions'
export function ThemeToggle({ currentTheme }: { currentTheme: string }) {
async function handleToggle() {
const next = currentTheme === 'light' ? 'dark' : 'light'
await updateUserTheme(next)
}
return <button onClick={handleToggle}>Toggle Theme</button>
}Common Mistakes
- Adding
'use client'to every component — this eliminates the bundle-size benefits of Server Components - Passing non-serializable values (class instances,
Date, functions) from Server to Client Components - Trying to use
async/awaitinside a Client Component's render function — move data fetching to Server Components - Importing server-only packages (like
fsorprisma) into Client Components — they will throw at runtime - Placing
'use client'at the top of a layout file, which forces the entire tree to be client-rendered
Best Practices
- Default to Server Components; add
'use client'only where interactivity is required - Push the
'use client'boundary as deep in the component tree as possible — wrap only the interactive leaf node - Use Server Actions for form submissions and mutations instead of client-side
fetchto API routes - Keep Client Components thin: fetch data in Server Components and pass it down as props
- Use URL search params to share state between Server and Client Components without prop drilling
Key Takeaways
- All components in the Next.js App Router are Server Components by default — opt into client rendering explicitly with
'use client' - Server Components reduce JavaScript bundle size by keeping code server-side; Client Components add JavaScript to the browser
- Props from Server to Client Components must be serializable JSON-compatible values
- Server Actions (
'use server') let Client Components call server-side code without creating an API route - A Server Component can render a Client Component, but not vice versa — plan your component tree accordingly
- Move the
'use client'boundary as deep as possible to maximize Server Component coverage - Never access database credentials,
process.envsecrets, or server-only packages inside Client Components - Measure performance impact with the Next.js bundle analyzer before optimizing aggressively
Advertisement