A developer on my team spent most of a day trying to work out why a page was shipping 340KB of JavaScript when it rendered a table of read-only data. No interactivity, no state, no event handlers.
The cause was one line, added months earlier, in a layout six levels up the tree: "use client". Someone had needed a hook for a dropdown, put the directive at the top of the file, and every component rendered below it — the entire subtree — became a client component. Including the table.
That is the single most common React Server Components mistake, and it comes from the mental model most people carry over from the pages era. Let me try to give the model that makes it click, because once it does, the rest of RSC stops being confusing.
Server Components Are Not SSR
This is the confusion at the root of everything else.
Server-side rendering takes a component, runs it on the server to produce HTML, sends that HTML, then sends the component's JavaScript so React can attach event handlers. The user sees content sooner. The bundle is unchanged. Every SSR'd component still ships to the browser.
A server component runs on the server and its code never reaches the browser at all. What gets sent is a description of the output — the rendered result, not the component. The component's imports, its dependencies, the markdown parser it used, none of it is in your bundle.
So SSR is about when HTML appears. Server components are about what code exists in the client. They solve different problems and they compose.
The practical consequence, and the one worth internalising: a server component can import a 200KB library and add zero bytes to what the user downloads.
// Server component. `marked` and `sanitize-html` never reach the browser.
import { marked } from "marked";
import sanitize from "sanitize-html";
import { db } from "@/lib/db";
export default async function Article({ id }: { id: string }) {
const post = await db.post.findUnique({ where: { id } }); // direct DB access
if (!post) notFound();
return (
<article dangerouslySetInnerHTML={{ __html: sanitize(marked(post.body)) }} />
);
}
No API route. No useEffect. No loading state. No fetch waterfall. The data access is a function call because the component runs where the database is.
The Directive Marks a Boundary, Not a File
Here is the misunderstanding that cost my colleague a day.
"use client" does not mean "this component is a client component." It means "this is where the client bundle starts." Everything imported below that point goes to the browser too, transitively, whether or not it needs to.
So the directive is not a label. It is a cut in the tree, and where you make the cut determines your bundle size. The rule that follows: push it as far down as it will go.
// Wrong: the whole page becomes client-side for one dropdown.
"use client";
export default function ProductPage({ product, reviews }) {
const [open, setOpen] = useState(false);
return (
<>
<ProductDetails product={product} /> {/* now client code */}
<ReviewList reviews={reviews} /> {/* now client code */}
<Dropdown open={open} onToggle={setOpen} />
</>
);
}
// Right: one small island, everything else stays on the server.
export default async function ProductPage({ id }) {
const [product, reviews] = await Promise.all([getProduct(id), getReviews(id)]);
return (
<>
<ProductDetails product={product} />
<ReviewList reviews={reviews} />
<VariantPicker variants={product.variants} /> {/* the only "use client" */}
</>
);
}
A page is a server component that renders a few interactive islands. Not a client page with some server data passed in.
The Composition Trick Everyone Misses
"But my server component needs to go inside a client component" — a modal, a tab panel, an accordion. The instinct is that the wrapper being interactive forces the contents to be client code.
It does not, if you pass them as children:
// Client: owns the open/closed state, knows nothing about its contents.
"use client";
export function Collapsible({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(!open)}>{open ? "Hide" : "Show"}</button>
{open && children}
</div>
);
}
// Server: HeavyServerTable is still a server component.
export default function Page() {
return (
<Collapsible>
<HeavyServerTable />
</Collapsible>
);
}
The client component receives already-rendered output as a prop. It never imports the server component, so the server component never enters the bundle. This one pattern resolves most "I can't make this a server component" situations.
What Actually Trips People Up
Only serialisable props cross the boundary. You cannot pass a function, a class instance, or a Date-with-methods from a server component to a client one. Pass IDs and plain data; put the behaviour on the client side.
Sequential awaits create waterfalls. Two independent await calls in one component run one after the other. Use Promise.all, or split into sibling components with Suspense so they load in parallel.
Suspense boundaries are the loading UI. Instead of a spinner state, wrap the slow part and let the rest of the page render immediately. This is a genuinely better default and it is easy to forget it exists.
Server-only code needs a guard. A file importing your database client should import server-only at the top, so an accidental client import fails at build time rather than leaking credentials into a bundle.
Caching is explicit now. The framework's caching behaviour has changed across versions and it is the thing most likely to surprise you. Be deliberate about what is static, what revalidates, and on what interval — do not rely on defaults you have not read.
Is It Worth It?
Honestly: for content-heavy applications, clearly yes. Blogs, documentation, e-commerce, dashboards that are mostly display. Removing data-fetching round trips and shipping less JavaScript are real, measurable wins, and the code is simpler than the useEffect-and-loading-state version it replaces.
For a highly interactive application — an editor, a design tool, something that is essentially a desktop app in a tab — most of your tree is genuinely interactive and RSC buys you less. You get a smaller shell and not much else.
And the ecosystem still has rough edges. Component libraries that assume client rendering, the caching semantics, error messages that point at the boundary rather than the cause. It is not friction-free.
But the core idea holds up: most of a typical page does not need to be interactive, and for years we shipped all of it to the browser anyway. That table my colleague was debugging had no business being in the bundle. Once the boundary is in the right place, it isn't.



