DEV Community

Cover image for React Server Components vs Client Components — When to Use Which
Safdar Ali
Safdar Ali

Posted on • Originally published at safdarali.in

React Server Components vs Client Components — When to Use Which

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?
│     └─ 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 needed)
│
└─ Unsure?
      → Start as Server Component.
      → Extract the interactive leaf to a small client file later.
Enter fullscreen mode Exit fullscreen mode

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

Pattern 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.


Pattern 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} />;
}
Enter fullscreen mode Exit fullscreen mode

Data crosses the boundary as serializable props. The server fetches; the client filters. This is the pattern I reach for most often.


Pattern 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 { useState } from "react";
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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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


Common mistakes I've seen in production

1. "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.

2. 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.

3. 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.

4. 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. 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.


Performance impact at a glance

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

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.


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 server 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.

For the full performance stack — images, fonts, caching, and RSC together — read my case study:
👉 How I Cut Load Time by 60% Using Next.js App Router

Questions or a Lighthouse export to review?
👉 safdarali.in/contact


More about how I work: safdarali.in/about
Projects and case studies: safdarali.in/projects
YouTube tutorials: youtube.com/@safdarali_


Talk is cheap. Show me the code. — Linus Torvalds

Top comments (0)