Most explanations of React Server Components start with implementation details—streaming, the RSC payload format, serialization boundaries—before establishing why any of it matters. That order makes RSCs feel like a backend concern bolted onto React. Flip the order, and they make a lot more sense.
Start with the actual problem
Before Server Components, every piece of interactive React shipped as JavaScript to the browser, whether it needed interactivity or not. A blog post's body, a product description, a static footer—all of it got bundled, sent over the wire, parsed, and hydrated, even though none of it ever changes after render. That's pure waste: bytes the user downloads and CPU the user's device spends, for zero benefit.
Server Components exist to let you say, explicitly: this piece of UI never needs to run in the browser. No JavaScript for it ships at all. It renders once, on the server, and the result streams down as HTML plus a lightweight description of where it sits in the tree.
The mental model: two different questions
Stop thinking "is this a server component or a client component" as a single question. It's actually two separate questions stacked together:
1. Where does the data come from? Server Components can read directly from a database, the filesystem, or an internal API with a secret key—no fetch-and-serialize round trip, no exposing that access to the browser.
import { db } from "@/lib/db"; // This runs only on the server. db is never bundled for the client. export default async function BlogPost({ params }: { params: { slug: string } }) { const post = await db.posts.findUnique({ where: { slug: params.slug } }); return <article>{/* render post */}</article>; }
2. Does it need to react to the user in the browser? Client Components are for anything that needs state, effects, event handlers, or browser-only APIs. This is the part that actually needs JavaScript shipped to the device.
"use client"; import { useState } from "react"; export function LikeButton({ postId }: { postId: string }) { const [liked, setLiked] = useState(false); return ( <button onClick={() => setLiked(!liked)}> {liked ? "♥ Liked" : "♡ Like"} </button> ); }
A page is rarely all-one-or-all-the-other. The realistic shape is a Server Component tree with Client Component "islands" wherever real interactivity lives:
export default async function BlogPost({ params }: { params: { slug: string } }) { const post = await db.posts.findUnique({ where: { slug: params.slug } }); return ( <article> <h1>{post.title}</h1> <PostBody content={post.content} /> {/* Server Component: no JS */} <LikeButton postId={post.id} /> {/* Client Component: hydrates */} </article> ); }
Only LikeButton's code reaches the browser. Everything else is HTML the browser just paints.
Why "can it be a Server Component" is the better default question
The instinct coming from older React is to default everything to a Client Component, because that's what "a React component" meant for a decade. In the App Router, flip the default: assume Server Component, and only add "use client" when something in the component genuinely requires the browser.
This matters because the cost of getting it wrong is asymmetric:
- Marking something client-side that didn't need to be costs bytes, hydration time, and a bit of runtime memory—for every user, on every visit.
- Marking something server-side that actually needed interactivity is caught immediately by an error (
useStateisn't available), so you find out fast and fix it.
Default to the option where mistakes are cheap and self-announcing.
The boundary rule that trips people up
Once a component is marked "use client", everything it imports and renders is also part of the client bundle—"use client" marks a boundary, not a single file. But you can still pass Server Components into a Client Component as children or props, and they stay server-rendered:
"use client"; export function Modal({ children }: { children: React.ReactNode }) { const [open, setOpen] = useState(false); return open ? <div className="modal">{children}</div> : null; }
import { Modal } from "@/components/modal"; import { ExpensiveServerContent } from "@/components/expensive-server-content"; export default function Page() { return ( <Modal> {/* Still a Server Component — Modal never sees its internals, just renders the already-resolved children */} <ExpensiveServerContent /> </Modal> ); }
This "slot" pattern is the escape hatch that lets interactive shells (modals, tabs, accordions) stay client-side while their content stays server-rendered.
Where this actually pays off
The benefit isn't abstract. On a real content-heavy page—a blog, a marketing site, a dashboard with mostly-static widgets—moving the non-interactive majority of the tree to Server Components routinely cuts the client JavaScript bundle for that page by more than half. That shows up directly in Core Web Vitals: less JavaScript to parse and execute means a faster Time to Interactive, especially on mid-range mobile devices where CPU, not network, is usually the bottleneck.
The mental model, distilled: don't ask "server or client" as a binary label for a whole feature. Ask it per component, default to server, and reach for client only at the exact leaves where the user actually needs to click, type, or see something update without a round trip.