React Server Components (RSCs) changed how the React team thinks about rendering — but the concept is often explained in a way that's more confusing than it needs to be. Here's the practical version.
The problem RSCs solve
Before Server Components, a typical React app shipped one thing to the browser: JavaScript. Every component — even ones that only fetched data and rendered static markup — had to be bundled, downloaded, and hydrated on the client.
That's wasteful for components that never need interactivity. A blog post body, a product description, a footer — none of that needs to run in the browser at all.
Server Components let you write components that:
- Run only on the server (or at build time, in a static export)
- Never ship their JavaScript to the client
- Can directly access server-only resources (the filesystem, environment secrets, databases) without an API layer
Server vs. Client Components
// app/components/PostBody.tsx — Server Component (default)
export default async function PostBody({ slug }: { slug: string }) {
const post = await getPostBySlug(slug); // runs on the server / at build time
return <article dangerouslySetInnerHTML={{ __html: post.html }} />;
}// app/components/LikeButton.tsx — Client Component
"use client";
import { useState } from "react";
export default function LikeButton() {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(!liked)}>{liked ? "Liked" : "Like"}</button>;
}The "use client" directive is the boundary. Everything below it (and its imports) gets bundled for the browser. Everything without it stays on the server.
When to reach for each
| Use a Server Component when... | Use a Client Component when... |
|---|---|
| You're fetching data | You need useState or useEffect |
| The content is static per request | You're attaching event listeners |
| You want zero client JS for that piece | You depend on browser-only APIs |
A common mistake
A frequent anti-pattern is slapping "use client" on a top-level layout "just to be safe." That drags every child component into the client bundle, even ones that didn't need it. Push "use client" as far down the tree as possible — ideally onto small, leaf components like a button or a form.
Static export and Server Components
In a fully static export (output: "export"), Server Components still work exactly as described — they just run once, at build time, instead of per-request. The output is plain HTML, so there's no runtime cost at all for visitors.
That's the sweet spot for a content site like this one: nearly everything is a Server Component, and "use client" is reserved for the handful of pieces that truly need interactivity — theme toggles, search boxes, and copy buttons.
Passing Server Components as children to Client Components
The "functions can't cross the boundary" rule above has a genuinely useful escape hatch: a Server Component can still be rendered inside a Client Component, as long as it's passed down as children (or another prop) from a parent Server Component, rather than imported directly by the Client Component itself:
// app/components/Modal.tsx — Client Component
"use client";
export default function Modal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return open ? <div className="modal">{children}</div> : null;
}// app/page.tsx — Server Component
import Modal from "./components/Modal";
import PostBody from "./components/PostBody"; // also a Server Component
export default function Page() {
return (
<Modal>
<PostBody slug="hello-world" /> {/* rendered on the server, passed in as children */}
</Modal>
);
}Modal never imports PostBody — it just renders whatever children it's given, without needing to know or care whether that content is a Server or Client Component. This "slot" pattern is how server-rendered content ends up inside client-interactive wrappers (a modal, a tab panel, an accordion) without violating the one-directional import rule.
Server Components and data fetching waterfalls
A subtler benefit of Server Components: because they can be async and fetch data directly, multiple sibling Server Components fetching independently don't necessarily create the same request waterfall a client-side useEffect-based fetch chain would, since React can begin rendering and streaming what's ready while still-loading Server Components resolve, rather than blocking the entire tree on the slowest fetch:
export default async function Page() {
return (
<>
<Suspense fallback={<Skeleton />}>
<SlowSection /> {/* fetches independently, streams in when ready */}
</Suspense>
<FastSection /> {/* renders immediately, doesn't wait on SlowSection */}
</>
);
}Wrapping a slower-fetching Server Component in <Suspense> lets the rest of the page render and reach the browser without waiting on it — the slow piece streams in afterward, rather than the whole page blocking until every fetch completes.
Server Components are one piece of the broader App Router model — see getting started with the Next.js App Router for the file conventions they sit alongside, and Next.js Middleware for the other major server-side-only piece of the same architecture.
Common mistakes
- Adding
"use client"reactively the moment a type error mentions hooks, without checking whether the interactivity actually belongs at that level of the tree — pushing it down to a smaller child component often avoids client-izing everything above it. - Passing a function as a prop from a Server Component to a Client Component. Functions can't cross the server/client boundary (they're not serializable) — pass data instead, and have the Client Component call its own local function, or use a Server Action for the server-side behavior.
- Importing a large third-party library (a charting library, a rich text editor) inside a Client Component "for convenience" when it's only used for one small interactive piece — that pulls the whole library into the client bundle for every page rendering that component, not just the ones that need it.
- Assuming a Server Component re-runs on every client interaction. Under static export, it ran once at build time — any data it fetched is frozen into the HTML until the next build, not refreshed per-visit.
Related reading
- React useEffect Cleanup: A Practical Guide — shares tags: react, javascript, web-development (same category).
- Next.js Middleware: What It's For and When to Avoid It — shares tags: nextjs, react, web-development.
- Getting Started with the Next.js App Router — shares tags: nextjs, react, web-development.
- The JavaScript Event Loop, Explained With Diagrams — shares tags: javascript, web-development.
- Core Web Vitals Explained: What Actually Affects Your Score — shares tags: web-development.