RSC vs Client Components: A Mental Model
React Server Components changed the default. Picking RSC vs Client well is now the single biggest perf decision in a Next.js app.
The App Router defaults every component to Server. Most React tutorials still default to Client. That mismatch is where new Next.js codebases sprout "use client" at the top of every file "to make hooks work." The result: a Server-first framework rendering like a SPA, with no benefits and all the bundle bloat.
1. The mental model
- Server Component — runs on the server, sends HTML, never reaches the browser as JS. Can
await fetch()and read databases directly. - Client Component — runs on the server and the browser. Sends JS. Required for interactivity (state, effects, event handlers).
The default is Server. Adding "use client" opts that file (and everything imported into it) into the Client bundle.
2. The cost of "use client"
"use client" is not a hint — it is a boundary. Every import below that boundary becomes part of the client bundle. The day you add "use client" to a button that imports your icon library, you have shipped 200 KiB of icons to the browser.
// page.tsx — Server Component
import { ProductCard } from "./ProductCard";
import { db } from "@/lib/db";
export default async function Page() {
const products = await db.product.findMany();
return products.map((p) => <ProductCard key={p.id} product={p} />);
}
ProductCard can be a Server Component too — even though it "renders products." It only needs to be Client if it has state, effects, or event handlers.
3. The pattern that works: islands of interactivity
Keep pages Server. Put Client only on the leaves that genuinely need it:
// LikeButton.tsx
"use client";
export function LikeButton({ initial }: { initial: number }) {
const [count, setCount] = useState(initial);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// ProductCard.tsx — still Server
import { LikeButton } from "./LikeButton";
export function ProductCard({ product }) {
return (
<div>
<h3>{product.title}</h3>
<LikeButton initial={product.likes} />
</div>
);
}
The page is rendered on the server. The product card is rendered on the server. Only the LikeButton ships to the browser. Total client JS: the button, not the catalogue.
4. Passing data across the boundary
Server → Client props must be serialisable. No functions, no classes, no Date that you forgot to .toISOString(). The serialisation is JSON-shaped.
The boundary goes one way: you cannot import a Server Component into a Client Component. You can, however, accept Server Components as children in a Client Component — a powerful pattern:
// Modal.tsx — Client (needs state for open/close)
"use client";
export function Modal({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
return open ? <div className="modal">{children}</div> : null;
}
// page.tsx — Server
<Modal>
<ServerOnlyDetails />
</Modal>
The Modal handles interactivity. The contents stay server-rendered. Best of both.
5. Data fetching changes shape
In RSC, you await in the component. There is no useEffect, no useQuery, no loading flag. The whole tree renders with data, in one round-trip from the client's perspective.
// Server Component
export default async function Page() {
const [user, orders] = await Promise.all([
getUser(),
getOrders(),
]);
return <OrdersList user={user} orders={orders} />;
}
This is the part SPA muscle memory fights hardest. There is no spinner state to wire up; the framework streams the response and shows the loading UI from loading.tsx while the server is busy.
6. When you genuinely need Client
- Anything with React state (
useState,useReducer). - Anything with effects (
useEffect,useLayoutEffect). - Event handlers (
onClick,onChange). - Browser-only APIs (
window,localStorage,IntersectionObserver). - Third-party libraries that depend on the above.
The rules of thumb
- Default to Server. Justify each Client boundary.
- Push the boundary as far down the tree as possible.
- Compose Server children into Client parents via
children. - When in doubt, look at the network tab and ask "what JS did this page actually ship?" — that is the truth-teller.