Back to Blog
August 19, 20269 min read1 views

Server Components vs Client Components: A Practical Decision Framework

Next.jsTypeScriptWeb Development
JL
Written by Julius Legaspi
LinkedInGitHub
Share
# 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 (

Products

{/* 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 (

Dashboard

{/* 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 (
{/* 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.

{/* 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 (
{/* Form fields */} {status === 'success' &&

Message sent!

} {status === 'error' &&

Failed to send message.

}
) } ``` ## 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 (

{user.name}

{user.email}

{/* 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 (

Dashboard

}> {/* 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 (
    {products.map(p =>
  • {p.name}
  • )}
) } ``` ### Data Fetching Server Components can fetch data directly without API routes: ```tsx // Server Component - direct database access async function UserList() { const users = await db.user.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
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 // 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 } ``` ## 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.

Comments (0)

Loading comments...