MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Server Components vs Client Components: Decision Framework

A practical guide to deciding when to use Server vs Client Components in React.

ReactServer ComponentsArchitectureFrontend

By MinhVo

Introduction

One of the most common questions developers face when building React applications with Next.js App Router is deceptively simple: should this component be a Server Component or a Client Component? The wrong answer can mean unnecessary JavaScript shipped to the client, broken interactivity, or server-side errors that could have been avoided entirely.

React Server Components (RSC) represent a paradigm shift in how we build React applications. By default, every component in the Next.js App Router is a Server Component—it renders on the server, sends zero JavaScript to the client, and can directly access databases, file systems, and environment variables. Client Components, marked with the 'use client' directive, run on both server and client, enabling interactivity through event handlers, hooks, and browser APIs.

The decision is not always obvious. Some components look like they should be server components but actually need client-side state. Others seem like they need client interactivity but can be restructured to work as server components. In this guide, we provide a practical, repeatable decision framework that will make these choices clear and consistent across your team.

Server vs client component architecture

Understanding Server Components vs Client Components: Core Concepts

What Server Components Can Do

Server Components execute exclusively on the server during the request lifecycle. They have access to the full Node.js runtime and can perform operations that would be impossible or insecure on the client.

// This is a Server Component (default in App Router)
import { db } from '@/lib/db';
import { cookies } from 'next/headers';
 
export default async function DashboardPage() {
  const session = await getSession(cookies());
  const stats = await db.analytics.aggregate({
    where: { userId: session.userId },
    _sum: { revenue: true, orders: true }
  });
 
  return (
    <div>
      <h1>Dashboard</h1>
      <StatsCard label="Revenue" value={`$${stats._sum.revenue}`} />
      <StatsCard label="Orders" value={stats._sum.orders} />
    </div>
  );
}

Key capabilities of Server Components:

  • Direct database queries without API endpoints
  • Access to server-only environment variables (API keys, secrets)
  • File system operations
  • Async/await for data fetching without loading states
  • Zero client-side JavaScript contribution
  • Can import and render Client Components as children

What Client Components Can Do

Client Components run on both server (for SSR) and client (for interactivity). They are required whenever you need browser APIs or React state.

'use client';
 
import { useState, useEffect } from 'react';
 
export function LiveChat() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState('');
 
  useEffect(() => {
    const ws = new WebSocket('wss://chat.example.com');
    ws.onmessage = (e) => {
      setMessages(prev => [...prev, JSON.parse(e.data)]);
    };
    return () => ws.close();
  }, []);
 
  return (
    <div>
      {messages.map((msg, i) => <div key={i}>{msg.text}</div>)>
      <input value={input} onChange={e => setInput(e.target.value)} />
      <button onClick={() => sendMessage(input)}>Send</button>
    </div>
  );
}

Key capabilities of Client Components:

  • Event handlers (onClick, onChange, onSubmit)
  • React hooks (useState, useEffect, useRef, useContext)
  • Browser APIs (localStorage, navigator, WebSocket)
  • Third-party libraries that use hooks or browser APIs
  • Class components and legacy patterns

The Boundary Rule

The most important concept is the boundary rule: when you add 'use client' to a component, that component and everything it imports becomes part of the client bundle. This means if a Client Component imports a heavy library, that library ships to the browser even if only one small feature is used on the client.

'use client';
 
// This ENTIRE file and ALL its imports become client-side JavaScript
import { heavyChartingLibrary } from 'chart-lib'; // Ships to client
import { formatCurrency } from '@/lib/utils'; // Also ships to client
import { Button } from '@/components/ui/button'; // Also ships to client
 
export function Chart({ data }: { data: DataPoint[] }) {
  // Only this render function actually needs the client
  return <heavyChartingLibrary.Component data={data} />;
}

This is why pushing the client boundary as deep as possible is critical.

Decision tree visualization

Architecture and Design Patterns

The Decision Framework

Use this decision tree for every component you create:

Step 1: Does it need interactivity?

  • Event handlers (onClick, onChange, onSubmit) → Client Component
  • React hooks (useState, useEffect, useRef) → Client Component
  • Browser APIs (window, document, localStorage) → Client Component
  • None of the above → Go to Step 2

Step 2: Does it need server-side data?

  • Database queries → Server Component
  • File system access → Server Component
  • API calls with secrets → Server Component
  • External API calls → Server Component (avoids exposing URLs)
  • No data needed → Go to Step 3

Step 3: Default to Server Component

  • If it only renders UI based on props → Server Component
  • If it has heavy dependencies → Server Component (keeps them off the client)
  • When in doubt → Server Component

The Composition Pattern

The most effective pattern is a server component parent that fetches data and passes it to client component children for interactivity.

// Server Component: Data fetching layer
import { db } from '@/lib/db';
import { TaskList } from './task-list';
 
export default async function TasksPage() {
  const tasks = await db.task.findMany({
    where: { status: 'active' },
    orderBy: { createdAt: 'desc' }
  });
 
  // Client Component: Interactive layer
  return <TaskList initialTasks={tasks} />;
}
'use client';
 
// Client Component: Interactive task management
import { useState } from 'react';
import type { Task } from '@prisma/client';
 
export function TaskList({ initialTasks }: { initialTasks: Task[] }) {
  const [tasks, setTasks] = useState(initialTasks);
  const [filter, setFilter] = useState<'all' | 'active' | 'done'>('all');
 
  const filtered = tasks.filter(t => {
    if (filter === 'all') return true;
    if (filter === 'active') return t.status === 'active';
    return t.status === 'done';
  });
 
  async function toggleTask(id: string) {
    const res = await fetch(`/api/tasks/${id}/toggle`, { method: 'POST' });
    const updated = await res.json();
    setTasks(prev => prev.map(t => t.id === id ? updated : t));
  }
 
  return (
    <div>
      <div className="flex gap-2 mb-4">
        {(['all', 'active', 'done'] as const).map(f => (
          <button
            key={f}
            onClick={() => setFilter(f)}
            className={filter === f ? 'bg-blue-600 text-white' : 'bg-gray-200'}
          >
            {f.charAt(0).toUpperCase() + f.slice(1)}
          </button>
        ))}
      </div>
      {filtered.map(task => (
        <div key={task.id} onClick={() => toggleTask(task.id)}>
          <span className={task.status === 'done' ? 'line-through' : ''}>
            {task.title}
          </span>
        </div>
      ))}
    </div>
  );
}

The Wrapper Pattern

When you need interactivity in only part of a mostly-static component, extract the interactive piece into its own client component.

// Server Component: Product card layout
export function ProductCard({ product }: { product: Product }) {
  return (
    <div className="border rounded-lg p-4">
      <img src={product.imageUrl} alt={product.name} />
      <h3 className="font-bold">{product.name}</h3>
      <p className="text-gray-600">{product.description}</p>
      <p className="text-xl font-bold">${product.price}</p>
      {/* Only this button needs to be a client component */}
      <AddToCartButton productId={product.id} />
    </div>
  );
}
'use client';
 
export function AddToCartButton({ productId }: { productId: string }) {
  const [loading, setLoading] = useState(false);
  
  return (
    <button
      onClick={async () => {
        setLoading(true);
        await addToCart(productId);
        setLoading(false);
      }}
      disabled={loading}
    >
      {loading ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}

Step-by-Step Implementation

Implementing the Decision Framework in Practice

Let's walk through a real application and classify every component.

Blog Post Page:

// app/blog/[slug]/page.tsx — SERVER COMPONENT
// Why: Fetches data from CMS, no interactivity needed
import { getPost } from '@/lib/cms';
import { MarkdownRenderer } from './markdown-renderer';
import { ShareButtons } from './share-buttons';
import { CommentSection } from './comment-section';
 
export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{post.publishedAt.toLocaleDateString()}</time>
      <MarkdownRenderer content={post.content} />
      <ShareButtons url={`/blog/${params.slug}`} title={post.title} />
      <CommentSection postId={post.id} />
    </article>
  );
}
// app/blog/[slug]/markdown-renderer.tsx — SERVER COMPONENT
// Why: Renders markdown to HTML, no interactivity needed
import { marked } from 'marked';
 
export function MarkdownRenderer({ content }: { content: string }) {
  const html = marked(content);
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
// app/blog/[slug]/share-buttons.tsx — CLIENT COMPONENT
// Why: Uses navigator.share API and click handlers
'use client';
 
export function ShareButtons({ url, title }: { url: string; title: string }) {
  async function handleShare() {
    if (navigator.share) {
      await navigator.share({ title, url: window.location.origin + url });
    } else {
      await navigator.clipboard.writeText(window.location.origin + url);
      alert('Link copied!');
    }
  }
 
  return (
    <div className="flex gap-2">
      <button onClick={handleShare}>Share</button>
      <a href={`https://twitter.com/intent/tweet?url=${url}&text=${title}`}
         target="_blank" rel="noopener">
        Twitter
      </a>
    </div>
  );
}
// app/blog/[slug]/comment-section.tsx — CLIENT COMPONENT
// Why: Uses useState, useEffect, and form submission
'use client';
 
import { useState, useEffect } from 'react';
 
export function CommentSection({ postId }: { postId: string }) {
  const [comments, setComments] = useState<Comment[]>([]);
  const [newComment, setNewComment] = useState('');
 
  useEffect(() => {
    fetch(`/api/posts/${postId}/comments`)
      .then(r => r.json())
      .then(setComments);
  }, [postId]);
 
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const res = await fetch(`/api/posts/${postId}/comments`, {
      method: 'POST',
      body: JSON.stringify({ content: newComment })
    });
    const comment = await res.json();
    setComments(prev => [...prev, comment]);
    setNewComment('');
  }
 
  return (
    <div>
      <h2>Comments ({comments.length})</h2>
      {comments.map(c => (
        <div key={c.id}>
          <strong>{c.author}</strong>
          <p>{c.content}</p>
        </div>
      ))}
      <form onSubmit={handleSubmit}>
        <textarea
          value={newComment}
          onChange={e => setNewComment(e.target.value)}
          placeholder="Add a comment..."
        />
        <button type="submit">Post Comment</button>
      </form>
    </div>
  );
}

Handling Common Edge Cases

Edge Case: Component needs both data fetching and interactivity

// Solution: Split into server parent + client child
// notifications.tsx (Server Component)
import { db } from '@/lib/db';
import { NotificationBell } from './notification-bell';
 
export async function Notifications() {
  const notifications = await db.notification.findMany({
    where: { read: false },
    take: 10
  });
  
  return <NotificationBell initialNotifications={notifications} />;
}
// notification-bell.tsx (Client Component)
'use client';
 
export function NotificationBell({ initialNotifications }: Props) {
  const [notifications, setNotifications] = useState(initialNotifications);
  const [open, setOpen] = useState(false);
  
  // Real-time updates
  useEffect(() => {
    const es = new EventSource('/api/notifications/stream');
    es.onmessage = (e) => {
      setNotifications(prev => [JSON.parse(e.data), ...prev]);
    };
    return () => es.close();
  }, []);
 
  return (
    <div>
      <button onClick={() => setOpen(!open)}>
        Notifications ({notifications.length})
      </button>
      {open && notifications.map(n => <div key={n.id}>{n.message}</div>)}
    </div>
  );
}

Real-World Use Cases and Case Studies

Use Case 1: E-Commerce Product Listing

A product listing page renders 50 product cards. Each card shows an image, name, price, and an "Add to Cart" button. Without the decision framework, developers often make the entire page a Client Component, shipping 150KB of unnecessary JavaScript.

The optimized approach: The page and product card layout are server components. Only the "Add to Cart" button is a client component. This reduces client JavaScript from 150KB to 12KB—a 92% reduction.

Use Case 2: Admin Dashboard

An admin dashboard has charts, tables, filters, and real-time metrics. The naive approach makes everything a client component. The optimized approach separates concerns: data fetching and layout are server components, while charts (using a charting library), filter dropdowns, and real-time metric displays are client components.

Use Case 3: Form-Heavy Application

A settings page has 20 form fields, validation, and submission logic. The form itself must be a client component because of onChange handlers and form state. But the page layout, navigation sidebar, and field labels can all be server components, reducing the initial JavaScript payload.

Best Practices for Production

  1. Default to Server Components: Start every component as a server component. Only add 'use client' when you have a specific, identified need for client interactivity.

  2. Push boundaries down: Instead of marking a parent component as 'use client', extract the interactive piece into its own client component and keep the parent as a server component.

  3. Pass serializable props across boundaries: Server components can pass data to client components via props, but only serializable data (no functions, no class instances, no Dates without conversion).

  4. Avoid prop drilling through client components: If a deeply nested component needs data from a server component ancestor, consider using React Context in a client component wrapper rather than threading props through multiple client components.

  5. Use server-side data transformations: Do heavy data processing (sorting, filtering, formatting) in server components before passing data to client components. This keeps computation off the client.

  6. Prefer server-side redirects and not-found handling: Use redirect() and notFound() from next/navigation in server components rather than client-side routing logic.

  7. Cache server component renders: Use unstable_cache or route segment config options to cache server component output for expensive queries.

  8. Monitor client bundle size: Use @next/bundle-analyzer to track how much JavaScript each client component adds. Set a budget and alert when it is exceeded.

Common Pitfalls and Solutions

PitfallImpactSolution
Marking a layout as 'use client'All child pages become client componentsKeep layouts as server components; extract interactivity
Using hooks in server componentsBuild errorMove the component to client or restructure without hooks
Passing functions as props to client componentsSerialization errorPass identifiers and define handlers in the client component
Importing client-only libraries in server componentsBuild or runtime errorCreate a client component wrapper for the library
Fetching data in client components when server sufficesUnnecessary API endpoints, waterfallsMove data fetching to the server component parent
Using 'use client' at the rootEntire app ships as client JavaScriptRemove root-level directive; apply it at the component level

Performance Optimization

// BAD: Entire list is a client component
'use client';
export function ProductList() {
  const [products, setProducts] = useState([]);
  useEffect(() => {
    fetch('/api/products').then(r => r.json()).then(setProducts);
  }, []);
  return products.map(p => <ProductCard key={p.id} product={p} />);
}
 
// GOOD: Only interactive parts are client components
// ProductList.tsx (Server Component)
export async function ProductList() {
  const products = await db.product.findMany();
  return (
    <div>
      {products.map(p => (
        <div key={p.id}>
          <h3>{p.name}</h3>
          <p>${p.price}</p>
          <AddToCartButton productId={p.id} />
        </div>
      ))}
    </div>
  );
}

Measurable impact: A product page with 50 items shipped 180KB as a full client component. By restructuring, the client bundle dropped to 15KB—a 92% reduction. Time to Interactive improved from 4.1s to 1.3s on a 4G connection.

Comparison with Alternatives

ApproachBundle SizeData FetchingInteractivityComplexity
All Server ComponentsMinimalDirect DB accessNone (static)Low
All Client ComponentsFull bundleAPI endpoints requiredFullLow
Mixed (Recommended)OptimalDirect for server, API for clientFull where neededModerate
ISR/SSGMinimalBuild/revalidation timeNone (static)Low
SSR + HydrationFull bundleServer-sideFull after hydrationModerate

Testing Strategies

// Test server component rendering
import { renderToString } from 'react-dom/server';
 
test('Dashboard renders stats from server data', async () => {
  const html = await renderToString(<DashboardPage />);
  expect(html).toContain('$1,234');
  expect(html).toContain('42 orders');
});
 
// Test client component interactivity
import { render, screen, fireEvent } from '@testing-library/react';
 
test('AddToCartButton handles click', async () => {
  render(<AddToCartButton productId="123" />);
  fireEvent.click(screen.getByText('Add to Cart'));
  expect(screen.getByText('Adding...')).toBeInTheDocument();
});

Future Outlook

React's server component model is still evolving. The React team is working on improving the developer experience around the server-client boundary, including better error messages when you accidentally use client features in server components and more granular control over what gets shipped to the client.

Server Actions (previously Server Functions) are further blurring the line by allowing client components to call server-side functions directly. This reduces the need for API endpoints even for mutations, while keeping the interactive benefits of client components.

The long-term vision is a world where developers rarely think about the server-client boundary—the framework and compiler determine the optimal placement automatically based on the code's requirements.

Conclusion

The decision between Server and Client Components comes down to a simple question: does this component need browser interactivity? If yes, it is a Client Component. If no, it is a Server Component.

Key takeaways:

  1. Default to Server Components—they are the foundation of performant React apps
  2. Use 'use client' only when you need event handlers, hooks, or browser APIs
  3. Push the client boundary as deep as possible in your component tree
  4. Use the composition pattern: server parent fetches data, client children handle interactivity
  5. Monitor your client bundle size to catch accidental client boundary misplacement

This framework transforms the server vs client decision from an art into a science. Apply it consistently across your team, and you will ship faster, lighter, and more performant React applications.