Web Development

Server Components vs Client Components: When to Use Each

React Server Components changed how we think about rendering. Here's a clear mental model for choosing server or client — with real examples and the trade-offs nobody mentions.

Marcus Webb

Senior Writer

Elena Ruiz

Fact Checked

Split scene of a server room doing heavy work connected by a light bridge to a laptop receiving UI pieces

React Server Components (RSC) are no longer experimental — they’re the default in Next.js App Router, they’ve landed in other frameworks in various forms, and they’ve quietly redrawn the architecture of a typical React application. Yet five years into this transition, the most common question I hear from working developers is still the basic one: “when should something be a Server Component and when should it be a Client Component?”

The confusion is understandable, because the names are slightly misleading and the mental model is genuinely new. Let’s fix that.

The mental model that actually works

Forget “server vs client” for a second. Think of your component tree as two kinds of work:

  • Describing what the page looks like based on data. This is rendering in the classic sense: fetch data, transform it, output markup.
  • Responding to the user after the page exists: clicks, typing, toggling, dragging, subscribing to changes.

Server Components do the first job. They run once per request (or per build) on the server, they can be async, they can touch your database or filesystem directly, and they ship zero JavaScript to the browser. What arrives at the client is a serialized description of UI, not code.

Client Components do the second job. They’re the React you’ve always known: useState, useEffect, event handlers, browser APIs. They render on the server too (for the initial HTML — that part surprises people), but their code is also shipped to the browser, where React hydrates them so they can respond to events.

The rule of thumb that follows:

Everything is a Server Component until it needs to react to the user. Then — and only then — it earns a 'use client' directive.

What each type can and cannot do

CapabilityServer ComponentClient Component
Fetch data with async/await✅ Directly in the component⚠️ Via effects, libraries or passed props
Access database / secrets✅ Safely❌ Never
useState / useReducer
useEffect, browser APIs
Event handlers (onClick…)
Ships JS to browser❌ Zero bytes✅ Bundle + hydration
Can import the other kind?✅ Can import Client Components⚠️ Only via children/props pattern

That last row hides the most important architectural fact in the whole system, so let’s spell it out.

Two component worlds: the server renders and ships nothing; the client hydrates and responds.

The boundary is contagious — downward

When you mark a file with 'use client', you’re not marking that component as client-side. You’re drawing a boundary: that module and everything it imports becomes part of the client bundle. A single careless 'use client' near the root of your tree can quietly drag your entire application to the browser, recreating the exact bundle-bloat problem RSC was designed to solve.

The escape hatch is composition. A Client Component can’t import a Server Component, but it can receive one as children (or any prop). The server renders the inner part, and the client shell wraps it:

// ThemeProvider.tsx — client shell
'use client';
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
}
// layout.tsx — server
import { ThemeProvider } from './ThemeProvider';
import { ArticleList } from './ArticleList'; // stays a Server Component!

export default function Layout() {
  return (
    <ThemeProvider>
      <ArticleList /> {/* rendered on the server, passed through as children */}
    </ThemeProvider>
  );
}

This “interactive shell, server-rendered filling” pattern is 80% of practical RSC architecture. Master it and the rest is details.

A concrete decision walkthrough

Take a typical article page on a content site — header, article body, share buttons, comments, related articles. Here’s how the split falls out:

  • Header / navigation — server, with one tiny client island for the mobile menu toggle.
  • Article body — pure server. It’s the biggest chunk of UI and needs exactly zero interactivity. Shipping a markdown renderer to the browser to display static text is the classic RSC-era mistake.
  • Share buttons — client, but small: the component is a few buttons and a navigator.clipboard call.
  • Comment form — client (controlled inputs), submitting via a server action or API route.
  • Related articles — server; it’s a database query and some cards.

Notice the shape: large static regions stay on the server; interactivity lives in small leaf components. If you find a Client Component with hundreds of lines of markup and one onClick, invert it — extract the button and let the markup go back to the server.

The trade-offs nobody mentions

RSC advocacy tends to skip the costs. In fairness, here’s what you’re actually trading:

Mental overhead is real. Every file now has an identity, and the import rules take time to internalize. Teams onboarding juniors should expect a few weeks of “why can’t I use useState here?” questions.

Serialization is a constraint. Props crossing the server→client boundary must be serializable: no functions, no class instances, no Dates without care. You’ll feel this the first time you try to pass an event handler down into a client island.

Caching becomes your new hard problem. When components fetch their own data on the server, understanding when that data refetches (per request? cached? revalidated?) becomes the main source of bugs. Next.js has revised its caching defaults more than once precisely because this was confusing.

Not every app benefits. A heavily interactive dashboard behind a login — where nearly every component reads live client state — gets little from RSC. A content-heavy, read-mostly site gets a lot. Match the tool to the shape of your app; our framework landscape overview maps which frameworks lean into which model.

Performance: why this matters beyond elegance

The payoff for getting the boundary right is measured in the metrics users feel. Less client JavaScript means less parse/compile/execute work on the main thread, which directly improves interaction responsiveness — the INP metric we dissected in our Core Web Vitals guide. It also shrinks the hydration cliff: the awkward period where the page looks ready but doesn’t respond.

On real projects I’ve measured, moving a content-heavy page from “everything hydrates” to a proper server/client split routinely cuts shipped JavaScript by 50–70% with zero feature loss. That’s not micro-optimization; that’s a different class of page.

Data fetching moves too. With RSC, the server component is the data layer for reads — your components can query directly instead of exposing an API endpoint for every widget. You still need real APIs for mutations and external consumers, of course; our comparison of REST, GraphQL and tRPC covers that decision.

The decision path: default to server, earn the client boundary, compose with children.

Quick reference: which do I use?

  • Displaying data, markdown, lists, cards → Server
  • Anything with useState, useEffect, refs to DOM → Client
  • Needs window, localStorage, IntersectionObserverClient
  • Reads database, uses API keys, heavy dependencies (syntax highlighters, markdown parsers) → Server
  • Form with instant validation → Client (the form), submitting to a server action
  • Context providers → Client shell accepting server children
  • “It’s static but it animates on scroll” → Server markup + CSS animations, or a tiny client wrapper

Final Thoughts

Server Components aren’t a new way to write React so much as a new default for where work happens. The framework finally lets the static majority of your UI be free — no bundle, no hydration — while interactivity is priced honestly, component by component.

The practical skill is boundary placement: default to server, push 'use client' to the leaves, and use the children pattern to keep interactive shells from swallowing your tree. Get that right and you’ll ship dramatically less JavaScript without sacrificing a single interaction — and your users, especially the ones on five-year-old Android phones, will feel the difference before they can name it.