Portfolio
All posts
9 min read

React Server Components: A Practical Mental Model

Server Components confuse even experienced React developers. This guide builds an intuition for the server/client boundary, when to reach for each, and the mistakes to avoid.

  • React
  • Next.js
  • Architecture

React Server Components are the most significant change to React's programming model in a decade — and also the most misunderstood. Even engineers comfortable with hooks and Suspense get tripped up by the server/client split. The confusion is almost always a mental model problem, not a syntax one. Build the right intuition and the rules stop feeling arbitrary.

The problem Server Components solve

Traditional React ships your entire component tree to the browser as JavaScript. The browser downloads it, parses it, and re-runs everything to "hydrate" the page. For a lot of UI — a blog post, a product page, a dashboard shell — that's wasteful. The markup never changes after render, yet the user pays to download the code that produced it.

Server Components let parts of your tree render only on the server. They never ship to the browser, never hydrate, and can talk directly to your database or filesystem. The client receives the resulting UI, not the code that made it.

A mental model: two runtimes, one tree

The cleanest way to think about it: you have one component tree, but it spans two runtimes. Some components run on the server, some run in the browser, and there's a boundary between them.

Server Components render once

A Server Component runs on the server, produces output, and is done. It has no state, no effects, and no event handlers, because there's no browser for those to live in. In exchange it gets superpowers: it can be async, await a database query directly, and read secrets that must never reach the client.

// A Server Component — note the async and the direct data access.
export default async function PostList() {
  const posts = await db.post.findMany()
  return posts.map((p) => <PostCard key={p.id} post={p} />)
}

Client Components are interactive islands

Anything that needs interactivity — state, effects, event handlers, browser APIs — is a Client Component, marked with the "use client" directive at the top of the file. These are the islands of interactivity floating in a sea of static, server-rendered UI.

"use client"

import { useState } from "react"

export function LikeButton() {
  const [liked, setLiked] = useState(false)
  return <button onClick={() => setLiked((v) => !v)}>{liked ? "♥" : "♡"}</button>
}

The boundary: "use client"

The directive doesn't mean "this component is special." It marks a doorway. Once you cross into "use client", that component and everything it imports becomes client code. Server Components can render Client Components, but not the other way around — a client component can only receive server-rendered UI as children or props.

What crosses the boundary

Props passed from a Server Component to a Client Component must be serializable — strings, numbers, plain objects, arrays. You cannot pass a function, a class instance, or a database connection across the line. This constraint is the source of most "why won't this work" moments, and it makes sense once you remember the data is literally serialized and sent over the network.

Common mistakes

Putting "use client" too high

The most frequent error is slapping "use client" at the top of a layout or page "to be safe." That opts the entire subtree into client rendering and throws away the benefit. Push the directive down to the smallest interactive leaf — the button, the form, the menu — and keep everything above it on the server.

Fetching waterfalls

Because Server Components can be async, it's tempting to await sequentially. Two independent queries should run in parallel. Kick off both promises, then await them together, or pass an unawaited promise down to a child that reads it under Suspense.

When to use which

A simple heuristic: default to Server Components, and reach for a Client Component only when you need interactivity. Static content, data fetching, and layout belong on the server. State, effects, and event handlers belong on the client. Most trees end up mostly server with small interactive islands — which is exactly the point.

Frequently Asked Questions

Are Server Components the same as server-side rendering?

No. SSR renders your client components to an HTML string on the server, then hydrates them in the browser — the JavaScript still ships. Server Components never ship their JavaScript at all. The two are complementary and often used together.

Can I use hooks in a Server Component?

No. Hooks like useState, useEffect, and useRef require a client runtime. A Server Component has no state or lifecycle, so those hooks aren't available. If you need them, that component should be a Client Component.

Do Server Components increase my bundle size?

The opposite — they reduce it. Code that runs only on the server never gets sent to the browser, so moving non-interactive logic into Server Components shrinks the JavaScript the client has to download and execute.

Should every component be a Server Component?

Default to yes, but not dogmatically. Anything interactive needs to be a Client Component. The goal is a tree that's mostly server-rendered with interactivity isolated to the leaves that actually need it.