Most React developers I've onboarded onto an App Router project have the same first reaction: "Oh, Server Components are like SSR." This is wrong in a way that takes weeks to unlearn. The mistake leads to apps that ship JavaScript they don't need, fetch data in the wrong places, and feel slow despite using the most "modern" stack on the market.
Here's the mental model that actually sticks.
Server Components Are Not SSR
Server-Side Rendering renders your entire React tree to HTML on the server, sends it to the client, and then rehydrates it. meaning the browser downloads the same component code, runs it again, and attaches event listeners. The server work is throwaway; the client work is full.
Server Components are different. They render to a special RSC payload (not HTML) that describes the rendered output. The browser receives that payload and reconstructs the UI without ever downloading the component code. There is no rehydration because there is no JavaScript to hydrate.
// This component never ships to the browser as JavaScript.
// Only its rendered output (text, props, tree shape) is sent.
async function ProductList() {
const products = await db.products.findMany();
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name} - ${p.price}</li>
))}
</ul>
);
}
The implication: Server Components are free in terms of bundle size. You can use any npm package, any database client, any heavy markdown renderer. and ship zero bytes to the user. This is the actual superpower.
Client Components Are Not "Browser Components"
The naming trips people up. A Client Component is not a component that only runs in the browser. It runs both on the server (during the initial render) and in the browser (during hydration and afterwards).
"use client";
import { useState } from "react";
export function Counter({ start }: { start: number }) {
const [count, setCount] = useState(start);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
This component is rendered on the server first (so the HTML response includes the initial markup), then hydrated in the browser (so the click handler works). Its source code ships to the browser as a JavaScript bundle.
A Client Component is one whose JavaScript ships to the browser. Nothing more.
The Boundary, Not the Component
The piece that finally clicked for me: think of "use client" as defining a boundary, not a label on a component. Everything from that file down (its imports, its children if they're imported into it) becomes part of the client bundle.
// app/dashboard/page.tsx - Server Component
import { ChartContainer } from "./chart-container"; // ← Client boundary starts here
import { DataTable } from "./data-table"; // ← Server (separate boundary)
export default async function Dashboard() {
const data = await fetchSlowAnalytics();
return (
<>
<ChartContainer initial={data} />
<DataTable rows={data.rows} />
</>
);
}
// app/dashboard/chart-container.tsx
"use client";
import { Chart } from "./chart"; // ← Now Chart is also Client
import { formatNumber } from "./utils"; // ← formatNumber ships to browser too
The boundary cascades down through imports. This is why placing "use client" carelessly explodes your bundle. every utility, every constant, every helper that the file imports comes along for the ride.
The pattern that scales: draw the boundary as small as possible, as deep as possible. If only your Submit Button needs interactivity, only the Submit Button is a Client Component. Its parent form can stay on the server.
Passing Server Data Into Client Components
You can pass props from a Server Component to a Client Component freely. but only serialisable values. Functions, class instances, Symbols, and Dates-as-instances will not survive the wire.
// Server Component
export default async function Page() {
const user = await getUser();
return <ProfileForm user={user} />; // ✅ Works - user is plain JSON
}
// Server Component
const handleClick = () => alert("hi");
return <Button onClick={handleClick} />; // ❌ Cannot pass functions
The exception: Server Actions can be passed as props. They serialise as references (Next.js handles the wiring).
// app/posts/page.tsx - Server Component
import { deletePost } from "./actions";
import { DeleteButton } from "./delete-button";
export default async function Page() {
return <DeleteButton onDelete={deletePost} />; // ✅ Server Action prop
}
Composition: The Children Pattern
The best pattern for mixing server and client work: pass Server Components as children to Client Components.
"use client";
import { useState } from "react";
export function Tabs({ children }: { children: React.ReactNode }) {
const [active, setActive] = useState(0);
return (
<div>
<nav>{/* tab buttons */}</nav>
<div>{children}</div> {/* ← Server Components render here just fine */}
</div>
);
}
// Server Component
import { Tabs } from "./tabs";
import { ProductList } from "./product-list"; // also Server
export default function Page() {
return (
<Tabs>
<ProductList /> {/* ← Server Component inside a Client wrapper */}
</Tabs>
);
}
This is the killer pattern. The Tabs component is interactive (state, click handlers. Client). The ProductList does database work (Server). They compose because Tabs doesn't import ProductList: it receives it as a child prop.
When Each Is the Right Choice
A simple decision tree:
- Needs database access? → Server Component
- Needs server-only secrets (API keys, etc.)? → Server Component
- Uses heavy npm packages (markdown renderers, syntax highlighters, ORMs)? → Server Component
- Needs
useState,useEffect, event handlers, or browser-only APIs? → Client Component - Needs to be interactive but doesn't need its children to be? → Client Component that takes Server Components as
children
The default in the App Router is correct: start with Server, push Client to the leaves.
The Cost of Getting It Wrong
The most common production failure mode: putting "use client" at the top of a layout file. Suddenly the entire route tree is client-side. The bundle balloons. First Contentful Paint regresses. Lighthouse scores tank.
The fix is almost always: lift the Server Components out, push the Client boundary down. Composition over wrapping.
Once this mental model clicks, the App Router stops feeling like a constraint and starts feeling like a tool that lets you write less JavaScript without losing any capability.