May 2026 · Guide · ~7 min read
React Server Components vs Client Components — When to Use Which
By Safdar Ali — frontend engineer, Bengaluru
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.tsxand keptlayout.tsxserver-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
fsor env secrets into a client bundle fails the build (good) or leaks patterns (bad). Keep data access inlib/server helpers. - Giant client components. A 400-line
Dashboard.tsxwith 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 KB | 255 KB | −38% |
| Shared vendor chunk | 198 KB | 198 KB | — |
| Hydrated component count | 24 | 6 | −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):
| Metric | All-client page | RSC-first page | Why it moved |
|---|---|---|---|
| LCP | 3.4s | 2.0s | HTML includes content; less JS before paint |
| TTI | 5.2s | 3.1s | Fewer components hydrating |
| Lighthouse Performance | 61 | 84 | Smaller main-thread work on load |
| First Contentful Paint | 2.1s | 1.3s | Server 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
- Default every new file to server — no directive.
- Add "use client" only on leaves that need state, effects, or events.
- Fetch on the server; pass serializable props into client islands.
- Never put "use client" on root layout unless you have no alternative.
- 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.