In modern React development with Next.js, understanding the distinction between Server and Client Components is fundamental to building performant, scalable applications. This lesson explores the React Server Components architecture, its benefits, and when to use each component type.
In traditional React applications, all components were rendered on the client-side. The browser would download JavaScript, parse it, and then render the UI. This approach worked but had several limitations:
Modern React with Next.js introduces Server Components as the default, fundamentally changing how we think about component architecture.
Server Components are React components that run exclusively on the server. They never ship to the client, which means:
// Server Component (default in Next.js)
async function UserProfile({ userId }: { userId: string }) {
// Direct database access - no API calls needed
const user = await db.user.findUnique({
where: { id: userId }
})
// File system access
const avatar = await fs.readFile(`/avatars/${user.avatar}`)
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<img src={avatar} alt="User avatar" />
</div>
)
}
Client Components are the traditional React components we're familiar with. They're marked with the 'use client' directive and run in the browser. Use them when you need:
onClick, onChange, etc.)useState, useReducerwindow, document, localStorageuseEffect, useLayoutEffect'use client'
import { useState, useEffect } from 'react'
function InteractiveCounter() {
const [count, setCount] = useState(0)
const [windowSize, setWindowSize] = useState({ width: 0, height: 0 })
useEffect(() => {
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
})
}
window.addEventListener('resize', handleResize)
handleResize() // Initial call
return () => window.removeEventListener('resize', handleResize)
}, [])
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
<p>Window: {windowSize.width} x {windowSize.height}</p>
</div>
)
}
In Next.js App Router, all components are Server Components by default. This is intentional and follows the server-first development mindset. You should only opt into client components when absolutely necessary.
This approach provides several benefits:
The boundary between Server and Client Components is crucial. Once you mark a component with 'use client', all components that import it (directly or indirectly) become Client Components.
// ✅ Server Component
function ServerComponent() {
return <ClientComponent />
}
// ✅ Client Component
'use client'
function ClientComponent() {
return <div>Interactive content</div>
}
// ❌ This won't work - Server Component importing Client Component
// and trying to use it in a Server context
function AnotherServerComponent() {
return <ClientComponent /> // This is fine
}
function ProblematicServerComponent() {
const client = <ClientComponent />
// Can't manipulate client component on server
return client
}
The most common pattern is having Server Components that contain Client Components for interactive parts:
// Server Component
async function ProductPage({ productId }: { productId: string }) {
const product = await getProduct(productId)
const reviews = await getProductReviews(productId)
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>Price: ${product.price}</p>
{/* Server-rendered reviews */}
<div>
<h2>Reviews</h2>
{reviews.map(review => (
<div key={review.id}>
<p>{review.content}</p>
<p>Rating: {review.stars}/5</p>
</div>
))}
</div>
{/* Client component for interactivity */}
<AddToCartButton productId={productId} />
</div>
)
}
// Client Component
'use client'
function AddToCartButton({ productId }: { productId: string }) {
const [isAdding, setIsAdding] = useState(false)
const handleAddToCart = async () => {
setIsAdding(true)
await fetch('/api/cart/add', {
method: 'POST',
body: JSON.stringify({ productId })
})
setIsAdding(false)
}
return (
<button
onClick={handleAddToCart}
disabled={isAdding}
>
{isAdding ? 'Adding...' : 'Add to Cart'}
</button>
)
}
A powerful pattern is passing children from Server Components to Client Components:
// Server Component
async function BlogPost({ postId }: { postId: string }) {
const post = await getPost(postId)
return (
<ClientLayout>
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
</ClientLayout>
)
}
// Client Component
'use client'
function ClientLayout({ children }: { children: React.ReactNode }) {
const [isDarkMode, setIsDarkMode] = useState(false)
return (
<div className={isDarkMode ? 'dark' : 'light'}>
<button onClick={() => setIsDarkMode(!isDarkMode)}>
Toggle Theme
</button>
{children} {/* Server-rendered content */}
</div>
)
}
Server Components excel at data fetching:
// Server Component with multiple data sources
async function Dashboard() {
// Parallel data fetching
const [user, posts, analytics] = await Promise.all([
getUser(),
getPosts(),
getAnalytics()
])
// Direct database queries
const recentActivity = await db.activity.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'desc' },
take: 10
})
// File system access
const report = await fs.readFile('/reports/daily-summary.pdf')
return (
<div>
<h1>Welcome back, {user.name}!</h1>
<StatsCard analytics={analytics} />
<RecentPosts posts={posts} />
<ActivityList activities={recentActivity} />
<ReportDownload report={report} />
</div>
)
}
Server Components have zero impact on your client-side JavaScript bundle. Consider this example:
// Without Server Components
// All this code ships to the client
function BlogPage() {
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/posts')
.then(res => res.json())
.then(data => {
setPosts(data)
setLoading(false)
})
}, [])
if (loading) return <div>Loading...</div>
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
)
}
// With Server Components
// Only the essential client code ships
async function BlogPage() {
const posts = await getPosts() // Server-side data fetching
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
)
}
Server Components eliminate the client-side data fetching waterfall:
// Traditional approach (waterfall)
function UserProfile() {
const [user, setUser] = useState(null)
const [posts, setPosts] = useState([])
const [comments, setComments] = useState([])
useEffect(() => {
// First request
fetch('/api/user')
.then(res => res.json())
.then(userData => {
setUser(userData)
// Second request (waits for first)
return fetch(`/api/posts?userId=${userData.id}`)
})
.then(res => res.json())
.then(postData => {
setPosts(postData)
// Third request (waits for second)
return Promise.all(
postData.map(post =>
fetch(`/api/comments?postId=${post.id}`)
.then(res => res.json())
)
)
})
.then(commentData => {
setComments(commentData.flat())
})
}, [])
// ... render logic
}
// Server Components (parallel)
async function UserProfile() {
// All data fetched in parallel on the server
const [user, posts, comments] = await Promise.all([
getUser(),
getUserPosts(),
getUserComments()
])
return (
<div>
<h1>{user.name}</h1>
<PostsList posts={posts} comments={comments} />
</div>
)
}
Problem: Marking components with 'use client' unnecessarily.
// ❌ Unnecessary client component
'use client'
function UserCard({ user }: { user: User }) {
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)
}
// ✅ Better as server component
function UserCard({ user }: { user: User }) {
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)
}
Solution: Only use 'use client' when you need interactivity, state, or browser APIs.
Problem: Server Components importing Client Components and trying to use them in server contexts.
// ❌ This won't work
function ServerComponent() {
const client = <ClientComponent />
// Can't manipulate client component here
return <div>{client}</div>
}
Solution: Pass Client Components as children or props:
// ✅ This works
function ServerComponent({ children }: { children: React.ReactNode }) {
return (
<div>
<h1>Server Content</h1>
{children}
</div>
)
}
// Usage
<ServerComponent>
<ClientComponent />
</ServerComponent>
Problem: Fetching data in Client Components when it could be done on the server.
// ❌ Client-side data fetching
'use client'
function PostList() {
const [posts, setPosts] = useState([])
useEffect(() => {
fetch('/api/posts')
.then(res => res.json())
.then(setPosts)
}, [])
return posts.map(post => <PostCard key={post.id} post={post} />)
}
// ✅ Server-side data fetching
async function PostList() {
const posts = await getPosts()
return posts.map(post => <PostCard key={post.id} post={post} />)
}
Server Actions allow you to define server-side functions that can be called from Client Components:
// Server Action
'use server'
async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
const post = await db.post.create({
data: { title, content }
})
revalidatePath('/posts') // Invalidate cache
return post
}
// Client Component using Server Action
'use client'
function CreatePostForm() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" />
<textarea name="content" placeholder="Content" />
<button type="submit">Create Post</button>
</form>
)
}
Server Components work seamlessly with React Suspense for streaming:
import { Suspense } from 'react'
function BlogPage() {
return (
<div>
<h1>My Blog</h1>
<Suspense fallback={<div>Loading posts...</div>}>
<PostList />
</Suspense>
<Suspense fallback={<div>Loading sidebar...</div>}>
<BlogSidebar />
</Suspense>
</div>
)
}
async function PostList() {
const posts = await getPosts()
return posts.map(post => <PostCard key={post.id} post={post} />)
}
async function BlogSidebar() {
const categories = await getCategories()
const recentPosts = await getRecentPosts()
return (
<aside>
<CategoriesList categories={categories} />
<RecentPosts posts={recentPosts} />
</aside>
)
}
Let's put it all together with a comprehensive example:
// Server Component - main page
async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id)
const relatedProducts = await getRelatedProducts(product.categoryId)
const reviews = await getProductReviews(params.id)
const inventory = await getProductInventory(params.id)
return (
<div>
<ProductDetails product={product} inventory={inventory} />
<ProductTabs
reviews={reviews}
relatedProducts={relatedProducts}
/>
<RecommendationsCarousel products={relatedProducts} />
</div>
)
}
// Server Component - product details
function ProductDetails({
product,
inventory
}: {
product: Product
inventory: Inventory
}) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>Price: ${product.price}</p>
<p>Stock: {inventory.quantity} available</p>
{/* Client component for interactivity */}
<AddToCartButton
productId={product.id}
inStock={inventory.quantity > 0}
/>
</div>
)
}
// Client Component - interactive elements
'use client'
function AddToCartButton({
productId,
inStock
}: {
productId: string
inStock: boolean
}) {
const [isAdding, setIsAdding] = useState(false)
const [quantity, setQuantity] = useState(1)
const handleAddToCart = async () => {
setIsAdding(true)
await addToCart(productId, quantity)
setIsAdding(false)
}
return (
<div>
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
min="1"
max={inStock ? 10 : 0}
/>
<button
onClick={handleAddToCart}
disabled={!inStock || isAdding}
>
{isAdding ? 'Adding...' : 'Add to Cart'}
</button>
</div>
)
}
// Server Component with client interactivity
function ProductTabs({
reviews,
relatedProducts
}: {
reviews: Review[]
relatedProducts: Product[]
}) {
return (
<TabContainer>
<Tab label="Reviews">
<ReviewList reviews={reviews} />
</Tab>
<Tab label="Related Products">
<ProductGrid products={relatedProducts} />
</Tab>
</TabContainer>
)
}
// Client Component for tab functionality
'use client'
function TabContainer({ children }: { children: React.ReactNode }) {
const [activeTab, setActiveTab] = useState(0)
return (
<div>
<div>
{React.Children.map(children, (child, index) => (
<button
key={index}
onClick={() => setActiveTab(index)}
className={activeTab === index ? 'active' : ''}
>
{(child as any).props.label}
</button>
))}
</div>
<div>
{React.Children.toArray(children)[activeTab]}
</div>
</div>
)
}
'use client', all imports become client components| Use Server Components when: | Use Client Components when: |
|---|---|
| Fetching data | Need interactivity (onClick, onChange) |
| Direct database/file access | Need state (useState, useReducer) |
| Rendering static content | Need browser APIs (window, localStorage) |
| SEO-critical content | Need lifecycle hooks (useEffect) |
| Large data processing | Need event listeners |
By following these principles and patterns, you'll build Next.js applications that are performant, maintainable, and provide excellent user experiences.