Skip to main content

May 2026 · Guide · ~7 min read

React Server Components vs Client Components — When to Use Which

By Safdar Ali — frontend engineer, Bengaluru

← All posts

I'm Safdar Ali. Roughly three quarters of professional React developers now ship on Next.js — which means most of us are living inside the App Router and its default: React Server Components (RSC). The docs explain what server and client components are. They rarely tell you which file gets which directive on Tuesday afternoon when a PM wants a filterable table by Friday.

In production, I've split dashboards, onboarding flows, and marketing shells between server and client boundaries more times than I can count. The pattern that saved us wasn't memorizing rules — it was a short decision tree, small client islands, and refusing to put "use client" on a parent just because one child needed a click handler. This guide is that tree, three real patterns from our repo, mistakes we actually made, and the bundle numbers that convinced our team to stop debating theory.

The mental model in sixty seconds

Server components run once on the server (or at build time). They can await databases and secrets. They render to HTML and a lightweight serializable payload — not a React bundle the browser has to execute. Client components are the React you already know: they ship JavaScript, hydrate, and can use useState, useEffect, browser APIs, and event listeners. In the App Router, every file is a server component until you add "use client" at the top. That default is the whole game: stay server until the UI proves it needs the client.

Decision flowchart — server or client?

Walk this top to bottom for any component. If you reach "Server Component," stop — do not add "use client".

START: New component for App Router
│
├─ Needs useState, useEffect, useReducer, useContext (client)?
│     └─ YES → Client Component ("use client")
│
├─ Needs onClick, onChange, onSubmit, or other DOM events?
│     └─ YES → Client Component
│
├─ Needs window, document, localStorage, or browser-only APIs?
│     └─ YES → Client Component
│
├─ Uses a library that calls hooks internally (charts, maps, some UI kits)?
│     └─ YES → Client Component (or wrap in a thin client leaf)
│
├─ Only fetches data, renders markup, links, static images?
│     └─ YES → Server Component (default — no directive)
│
└─ Unsure?
      → Start as Server Component.
      → Extract the interactive leaf to a small client file later.

The expensive mistake is the reverse: marking a layout or page "use client" because a sidebar toggle lives somewhere underneath. Client boundaries are contagious downward — everything imported into a client file becomes part of the client graph unless you pass server children as children.

Three real patterns from production

Example 1 — Read-only dashboard shell (server)

On one dashboard I shipped, the workspace overview showed org name, plan tier, last-sync time, and a grid of read-only metric cards. No interactivity on first paint — just data from our API. This belongs entirely on the server.

// app/dashboard/page.tsx — Server Component (no directive)
import { getWorkspace } from "@/lib/api";

export default async function DashboardPage() {
  const workspace = await getWorkspace(); // secrets + DB stay on server

  return (
    <main>
      <h1>{workspace.name}</h1>
      <p>Plan: {workspace.plan}</p>
      <MetricsGrid stats={workspace.stats} />
    </main>
  );
}

Zero bytes of component JavaScript for this tree in the browser bundle. Users see real numbers in the first HTML response — not a spinner while useEffect fires.

Example 2 — Filterable table (client island)

The same dashboard needed a searchable, sortable project table. That requires local state and keyboard events — client only. We kept the page server-side and imported one client leaf.

// components/ProjectTable.tsx
"use client";

import { useMemo, useState } from "react";

export function ProjectTable({ projects }: { projects: Project[] }) {
  const [query, setQuery] = useState("");
  const filtered = useMemo(
    () => projects.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())),
    [projects, query]
  );

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search…" />
      <table>{/* rows from filtered */}</table>
    </>
  );
}

// app/dashboard/projects/page.tsx — still a Server Component
export default async function ProjectsPage() {
  const projects = await getProjects();
  return <ProjectTable projects={projects} />;
}

Data crosses the boundary as serializable props. The server fetches; the client filters. This is the pattern I reach for most often when people ask about react server components vs client components in practice.

Example 3 — Modal inside a server layout (composition)

A marketing site I worked on used a server layout with static copy, but the header had a mobile menu and a "Book demo" modal. We split: server layout wraps a client header; the modal is a separate client file so the rest of the page never hydrates.

// app/(marketing)/layout.tsx — Server Component
import { SiteHeader } from "@/components/SiteHeader";

export default function MarketingLayout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <SiteHeader />
      {children}
    </>
  );
}

// components/SiteHeader.tsx
"use client";
import { DemoModal } from "./DemoModal";

export function SiteHeader() {
  const [open, setOpen] = useState(false);
  return (
    <header>
      <button type="button" onClick={() => setOpen(true)}>Book demo</button>
      <DemoModal open={open} onClose={() => setOpen(false)} />
    </header>
  );
}

Only SiteHeader and DemoModal pay hydration cost — not the hero, footer, or case-study sections below.

Common mistakes I've seen in production

  • "use client" on the root layout. One engineer added it for a theme toggle. Every page became a client entry. We moved the toggle to ThemeProvider.tsx and kept layout.tsx server-only. First-route JS dropped ~90KB parsed.
  • Fetching in useEffect what the server could fetch once. Settings pages showed empty labels, then populated — bad UX and duplicate API load. Moving fetches into async server components fixed both.
  • Importing server-only modules into client files. Accidentally pulling fs or env secrets into a client bundle fails the build (good) or leaks patterns (bad). Keep data access in lib/ server helpers.
  • Giant client components. A 400-line Dashboard.tsx with one chart. Splitting static chrome back to the server cut Time to Interactive noticeably on mid-tier Android.

Bundle size — before vs after boundaries

I measured one dashboard route with @next/bundle-analyzer after refactoring a page that had been fully client-rendered (Create React App habits carried into App Router). Same features; different boundaries.

Bundle (first load)Before (all client)After (RSC + islands)Change
Route JS (parsed)412 KB255 KB−38%
Shared vendor chunk198 KB198 KB
Hydrated component count246−75%

Vendor stayed flat; the win was not shipping render logic for static UI. For a deeper performance pass on the same stack, see my case study How I Cut Load Time by 60% Using Next.js App Router.

Performance impact at a glance

Lab numbers from the same refactor (Lighthouse mobile, throttled 4G, median of three runs on staging):

MetricAll-client pageRSC-first pageWhy it moved
LCP3.4s2.0sHTML includes content; less JS before paint
TTI5.2s3.1sFewer components hydrating
Lighthouse Performance6184Smaller main-thread work on load
First Contentful Paint2.1s1.3sServer HTML streams earlier

RSC is not magic — slow APIs and unoptimized images still hurt. But choosing the right boundary is often the highest-leverage architectural decision on a Next.js app before you touch CDN config.

TL;DR checklist

  1. Default every new file to server — no directive.
  2. Add "use client" only on leaves that need state, effects, or events.
  3. Fetch on the server; pass serializable props into client islands.
  4. Never put "use client" on root layout unless you have no alternative.
  5. Run the bundle analyzer after any large feature — boundaries are invisible until you measure.

Closing

The debate over react server components vs client components ends quickly in production: server for data and markup, client for interactivity, and a hard rule against lazy "use client" on parents. I ship this way in production and teach it on my channel — more about how I work on safdarali.in/about.

If your App Router app feels like a SPA wearing a server costume, start with the flowchart above, then read the performance case study for the full stack of wins: How I Cut Load Time by 60% Using Next.js App Router. Questions or a Lighthouse export to review? safdarali.in/contact.

"Talk is cheap. Show me the code."