Back to Blog
August 19, 20269 min read1 views
Server Components vs Client Components: A Practical Decision Framework
Next.jsTypeScriptWeb Development
# Server Components vs Client Components: A Practical Decision Framework
## Introduction
One of the most confusing aspects of modern React development is understanding when to use Server Components versus Client Components. This confusion is understandable — the boundary between server and client has become more nuanced with React Server Components.
After building several production applications with App Router, I've developed a practical decision framework that eliminates the guesswork.
## The Fundamental Difference
Before diving into decisions, let's clarify what each component type actually does.
### Server Components
Server Components run on the server and send rendered HTML to the client. They can:
- Access databases and file systems directly
- Use async/await for data fetching
- Keep sensitive logic on the server
- Reduce client-side JavaScript bundle size
**They cannot:**
- Use React hooks (`useState`, `useEffect`, etc.)
- Handle user interactions (clicks, form submissions)
- Access browser APIs
- Use context providers
### Client Components
Client Components run in the browser after the initial HTML is sent. They can:
- Use React hooks for state and effects
- Handle user interactions
- Access browser APIs
- Use context providers
**They cannot:**
- Access databases directly (without API routes)
- Keep secrets secure
- Reduce bundle size (they ship JavaScript to the client)
## The Decision Framework
Use this flowchart to decide which component type to use:
```
Start
↓
Does it need useState, useEffect, or other hooks?
↓ Yes → Client Component
↓ No ↓
Does it handle user interactions (clicks, forms)?
↓ Yes → Client Component
↓ No ↓
Does it access browser APIs?
↓ Yes → Client Component
↓ No ↓
Does it use context providers?
↓ Yes → Client Component
↓ No ↓
Does it need real-time updates?
↓ Yes → Client Component (with Server Component wrapper)
↓ No ↓
Server Component ✓
```
## Real-World Examples
Let me walk through common scenarios and show the decision process.
### Example 1: Product Listing Page
**Requirements:**
- Fetch products from database
- Display in a grid
- Allow filtering by category
- Show product details on click
**Decision:**
- Product fetching → Server Component (database access)
- Filtering → Client Component (user interaction)
- Product grid → Server Component (static display)
- Product modal → Client Component (interaction)
```tsx
// app/products/page.tsx (Server Component)
import { db } from '@/lib/database'
import { ProductFilters } from './product-filters'
import { ProductGrid } from './product-grid'
export default async function ProductsPage() {
const products = await db.product.findMany()
return (
{/* Client Component */}
{/* Server Component */}
)
}
```
```tsx
// app/products/product-filters.tsx (Client Component)
'use client'
import { useState } from 'react'
export function ProductFilters() {
const [category, setCategory] = useState('all')
return (
)
}
```
### Example 2: Dashboard with Real-Time Stats
**Requirements:**
- Display user statistics
- Update stats every 30 seconds
- Allow time range selection
- Export data to CSV
**Decision:**
- Initial stats fetch → Server Component
- Real-time updates → Client Component
- Time range selection → Client Component
- CSV export → Client Component (browser API)
```tsx
// app/dashboard/page.tsx (Server Component)
import { getStats } from '@/lib/data'
import { StatsDisplay } from './stats-display'
import { TimeRangeSelector } from './time-range-selector'
export default async function DashboardPage() {
const initialStats = await getStats('all')
return (
{/* Client Component */}
{/* Client Component */}
)
}
```
```tsx
// app/dashboard/stats-display.tsx (Client Component)
'use client'
import { useState, useEffect } from 'react'
interface StatsDisplayProps {
initialStats: Stats
}
export function StatsDisplay({ initialStats }: StatsDisplayProps) {
const [stats, setStats] = useState(initialStats)
useEffect(() => {
const interval = setInterval(async () => {
const updated = await fetch('/api/stats').then(r => r.json())
setStats(updated)
}, 30000)
return () => clearInterval(interval)
}, [])
return (
{/* Client Component */}
)
}
```
```tsx
// app/contact/contact-form.tsx (Client Component)
'use client'
import { useState } from 'react'
export function ContactForm() {
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setStatus('loading')
try {
const formData = new FormData(e.currentTarget)
await fetch('/api/contact', { method: 'POST', body: formData })
setStatus('success')
} catch {
setStatus('error')
}
}
return (
)
}
```
## Common Patterns and Solutions
### Pattern 1: Server Component with Client Wrapper
When you need both server-side data fetching and client-side interactivity:
```tsx
// Server Component
async function UserProfile({ userId }: { userId: string }) {
const user = await getUser(userId)
return (
{/* Client Component */}
)
}
// Client Component
'use client'
function UserActions({ userId }: { userId: string }) {
const [isFollowing, setIsFollowing] = useState(false)
return (
)
}
```
### Pattern 2: Server Component with Context Provider
When you need to provide context to Client Components:
```tsx
// Server Component
async function ThemeProviderWrapper({ children }: { children: React.ReactNode }) {
const theme = await getTheme()
return (
{children}
)
}
// Client Component
'use client'
function ThemeProvider({
initialTheme,
children
}: {
initialTheme: Theme
children: React.ReactNode
}) {
const [theme, setTheme] = useState(initialTheme)
return (
{children}
)
}
```
### Pattern 3: Server Component with Streaming
When you want to show loading states while data loads:
```tsx
// Server Component
async function DataDashboard() {
return (
}>
{/* Async Server Component */}
}>
{/* Async Server Component */}
)
}
async function Stats() {
const stats = await fetchStats()
return
}
async function Chart() {
const data = await fetchChartData()
return
}
```
## Performance Implications
### Bundle Size
Server Components don't ship JavaScript to the client:
```tsx
// This component sends NO JavaScript to the client
async function ProductList() {
const products = await db.product.findMany()
return (
}
// vs. Client Component - needs API route
'use client'
function UserList() {
const [users, setUsers] = useState([])
useEffect(() => {
fetch('/api/users').then(r => r.json()).then(setUsers)
}, [])
return
}
```
### Caching
Server Components support advanced caching:
```tsx
async function ProductPage({ params }: { params: { id: string } }) {
const product = await fetch(`https://api.example.com/products/${params.id}`, {
next: { revalidate: 3600 } // Cache for 1 hour
}).then(r => r.json())
return
}
```
## Testing Considerations
### Server Components
Test Server Components by mocking data fetching:
```tsx
import { render } from '@testing-library/react'
import ProductList from './product-list'
jest.mock('@/lib/database', () => ({
db: {
product: {
findMany: jest.fn().mockResolvedValue([
{ id: 1, name: 'Product 1' },
{ id: 2, name: 'Product 2' }
])
}
}
}))
test('renders product list', async () => {
const { findByText } = render( )
expect(await findByText('Product 1')).toBeInTheDocument()
})
```
### Client Components
Test Client Components with user interactions:
```tsx
import { render, screen, fireEvent } from '@testing-library/react'
import Counter from './counter'
test('increments counter', () => {
render( )
fireEvent.click(screen.getByText('Increment'))
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
```
## Migration Strategy
### Start with Server Components
When migrating existing applications:
1. **Identify components that don't need client-side features**
2. **Move data fetching to the component level**
3. **Remove unnecessary `useEffect` calls**
4. **Keep Client Components for interactive elements**
### Gradual Migration
You don't have to migrate everything at once:
```tsx
// Mixed Server and Client Components
export default function Page() {
return (
{/* Could be either */}
{/* Server */}
{/* Client */}
{/* Could be either */}
)
}
```
## Common Mistakes to Avoid
### 1. Making Everything a Client Component
**Mistake:**
```tsx
'use client'
export default function Page() {
return // Move to Client Component
}
'use client'
function Counter() {
const [count, setCount] = useState(0)
return
}
```
### 3. Passing Functions as Props
**Mistake:**
```tsx
// Server Component
export default function Page() {
const handleClick = () => console.log('clicked') // Can't pass to Client!
return
}
```
**Solution:**
```tsx
// Server Component
export default function Page() {
return // Client Component handles its own logic
}
// Client Component
'use client'
function Button() {
const handleClick = () => console.log('clicked')
return
}
```
## Conclusion
The Server Components vs Client Components decision doesn't have to be confusing. Use this framework:
1. **Default to Server Components** — they're more performant
2. **Use Client Components only when needed** — for interactivity, hooks, or browser APIs
3. **Keep the boundary clean** — don't mix concerns
4. **Test appropriately** — different testing strategies for each type
The key insight is that Server Components are about data and rendering, while Client Components are about interactivity and state. Once you internalize this distinction, the decision becomes natural.
---
**Need help implementing Server Components in your project?** [Contact me](/contact) to discuss your architecture.
Products
Dashboard
{/* Stats cards */}
)
}
```
### Example 3: Contact Form
**Requirements:**
- Display form fields
- Validate input
- Submit to API
- Show success/error messages
**Decision:**
- Form display → Server Component (initial render)
- Form validation → Client Component (real-time feedback)
- Form submission → Client Component (interaction)
- Success/error messages → Client Component (state)
```tsx
// app/contact/page.tsx (Server Component)
import { ContactForm } from './contact-form'
export default function ContactPage() {
return (
Contact Us
Get in touch with our team.
{user.name}
{user.email}
Dashboard
-
{products.map(p =>
- {p.name} )}
Hello World
}
```
**Solution:**
```tsx
export default function Page() {
return Hello World
}
```
### 2. Using Hooks in Server Components
**Mistake:**
```tsx
export default function Page() {
const [count, setCount] = useState(0) // Error!
return
}
```
**Solution:**
```tsx
export default function Page() {
return Comments (0)
Loading comments...