DEV Community

Cover image for React Server Components vs Client Components in Next.js — A Practical Decision Framework
Aon infotech
Aon infotech

Posted on

React Server Components vs Client Components in Next.js — A Practical Decision Framework

The App Router's Server Component default confuses a lot of developers coming from Pages Router. Everything is a Server Component unless you opt out. Interactivity requires Client Components. Knowing when to use which — and where to draw the boundary — is the most important architectural decision in an App Router application.

Here's a practical framework for making that decision, including the patterns I've found most useful building generation interfaces at pixova.io.


The Core Distinction

Server Components run on the server, have access to server-side resources (databases, file system, environment variables), and never ship JavaScript to the browser. They render to HTML.

Client Components run in the browser. They can use React hooks (useState, useEffect), handle DOM events, access browser APIs, and create interactive UI.

The practical question isn't "do I want this to be interactive?" It's "what is the minimum client-side JavaScript this component actually needs?"


When to Use Server Components (Default)

Use Server Components for anything that:

Fetches data. Data fetching on the server avoids client-side loading states and keeps data fetching logic close to where data is used.

// Server Component — no 'use client' needed
async function ProductList() {
  const products = await db.products.findMany({ where: { active: true } });

  return (
    <ul>
      {products.map(p => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

Renders conditionally based on server state. Authorization checks, feature flags, A/B testing logic.

async function AdminSection() {
  const user = await getCurrentUser();
  if (!user?.isAdmin) return null;

  return <AdminDashboard />;
}
Enter fullscreen mode Exit fullscreen mode

Is primarily static HTML. Navigation, headers, footers, content sections, layouts — anything that doesn't need to update or respond to user input.

Needs server-only data. API keys, database connections, server-only environment variables — these should never reach the client.


When to Use Client Components

Opt into Client Components ('use client') for components that need:

React hooks for local state:

'use client';
import { useState } from 'react';

function Accordion({ items }) {
  const [openIndex, setOpenIndex] = useState(null);

  return (
    <div>
      {items.map((item, i) => (
        <div key={i}>
          <button onClick={() => setOpenIndex(i === openIndex ? null : i)}>
            {item.title}
          </button>
          {openIndex === i && <p>{item.content}</p>}
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Browser event handlers. Any onClick, onChange, onSubmit, onFocus — these can only run in the browser.

Browser APIs. window, document, localStorage, navigator, geolocation, clipboard API — only available client-side.

Real-time updates. WebSockets, SSE, polling — requires client-side code.

Third-party client-side libraries. Libraries that use browser APIs internally need to run client-side.


The Boundary Pattern

The most important pattern: push 'use client' as deep into the component tree as possible.

Wrong — unnecessarily large client boundary:

// Entire page becomes a Client Component just because of one button
'use client';
import { useState } from 'react';
import { ProductList } from './ProductList'; // Now this is also client-side

export default function ProductPage({ products }) {
  const [filter, setFilter] = useState('all');

  return (
    <div>
      <h1>Products</h1>
      <FilterButton filter={filter} onChange={setFilter} /> // Needs client
      <ProductList products={products} filter={filter} />   // Doesn't need client
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Right — minimal client boundary:

// Page remains a Server Component
import { FilterableProductList } from './FilterableProductList';

export default async function ProductPage() {
  const products = await db.products.findMany();

  return (
    <div>
      <h1>Products</h1>
      <FilterableProductList products={products} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
// Only the interactive part is a Client Component
'use client';
import { useState } from 'react';

export function FilterableProductList({ products }) {
  const [filter, setFilter] = useState('all');

  const filtered = products.filter(p => 
    filter === 'all' || p.category === filter
  );

  return (
    <div>
      <select value={filter} onChange={e => setFilter(e.target.value)}>
        <option value="all">All</option>
        <option value="sale">Sale</option>
      </select>
      <ul>
        {filtered.map(p => <li key={p.id}>{p.name}</li>)}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The second version keeps the data fetching on the server while limiting client JavaScript to the interactive parts.


Passing Server Data to Client Components

Server Components can pass data to Client Components through props. The data must be serializable (JSON-compatible):

// Server Component
import { UserProfile } from './UserProfile';

async function UserPage({ userId }) {
  const user = await db.users.findUnique({ where: { id: userId } });

  // Pass serializable data only — no functions, no class instances
  return (
    <UserProfile 
      id={user.id}
      name={user.name}
      email={user.email}
      // Not: user object with methods, Date objects that need serialization
    />
  );
}

// Client Component
'use client';
function UserProfile({ id, name, email }) {
  const [isEditing, setIsEditing] = useState(false);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

What can be passed: strings, numbers, booleans, plain objects, arrays of these.
What can't be passed: functions, class instances, non-serializable objects, React Server Component output.


The Interleaving Pattern

Server Components can render Client Components. Client Components can't render Server Components directly — but they can render children that happen to be Server Components.

// Server Component
import { Modal } from './Modal'; // Client Component
import { ExpensiveServerData } from './ExpensiveServerData'; // Server Component

async function Page() {
  return (
    <Modal>
      <ExpensiveServerData /> {/* Server Component passed as children */}
    </Modal>
  );
}
Enter fullscreen mode Exit fullscreen mode
// Client Component — receives children without knowing their type
'use client';
function Modal({ children }) {
  const [isOpen, setIsOpen] = useState(true);

  if (!isOpen) return null;

  return (
    <div className="modal">
      {children} {/* Could be Server Component output */}
      <button onClick={() => setIsOpen(false)}>Close</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This pattern lets interactive Client Components wrap expensive Server Component data fetching without making the data fetching client-side.


Common Mistakes

Adding 'use client' to leaf components unnecessarily. If a component doesn't need hooks or browser APIs, it should be a Server Component. Many developers add it "just in case" and lose the benefits.

Trying to use server-only APIs in Client Components. headers(), cookies(), direct database calls — these don't work in Client Components. Move them up to a Server Component parent.

Passing non-serializable data as props. Passing a full database row object, a Date object, or a function from a Server Component to a Client Component will fail at runtime or cause serialization issues.

Making layout files Client Components. Layout files should almost always be Server Components — they don't need interactivity, and making them client-side forces all nested components into the client bundle unnecessarily.


A Quick Decision Checklist

Before writing 'use client', ask:

  • Does this component use useState, useReducer, or custom hooks that use them? → Client
  • Does this component use useEffect or useLayoutEffect? → Client
  • Does this component attach browser event handlers? → Client
  • Does this component access window, document, or browser APIs? → Client
  • Does this component use a library that requires browser APIs? → Client If none of the above: leave it as a Server Component.

Then ask where the boundary belongs:

  • Can I split this component so only the interactive part needs 'use client'?
  • Can I extract the interactive part into a smaller child component? The goal is the smallest possible client bundle — every byte of JavaScript that doesn't need to be in the browser improves page load performance.

Summary

Server Components are the default in App Router — fast, zero JavaScript cost, direct data access. Use them for everything that doesn't require browser interactivity.

Client Components handle interactivity — hooks, events, browser APIs. Use them for the minimum necessary to make UI interactive.

The key architectural discipline: push 'use client' as deep as possible, keep Server Components doing data work, and pass serializable data down through props.

Top comments (0)