The thing that took me longest to internalize about React Server Components is that they aren't a React feature you enable — they're a build-time and routing convention that a framework has to implement, with React's role being the component model both sides share. Understanding where the server/client line actually sits matters more than memorizing the "use client" directive.
The mental model: server-only, zero JS shipped
A Server Component runs exclusively on the server (or at build time), produces its output as part of the initial response, and never re-renders in the browser — its code, and any dependencies it imports, never end up in the client JS bundle at all. That's what makes it different from server-side rendering a normal component: SSR still ships the component's code to hydrate later, an RSC doesn't ship at all. Because it never runs client-side, a Server Component can do things a Client Component structurally can't: query a database directly, read a filesystem, or use a secret API key, without any of that code being reachable by the browser.
// app/dashboard/page.tsx — no "use client", runs only on the server
import { db } from '@/lib/db';
export default async function DashboardPage() {
const projects = await db.query('select id, name from projects limit 20');
return (
{projects.map((p) => (
- {p.name}
))}
);
}
The "use client" boundary
Everything is a Server Component by default in a framework that supports RSC; you opt a file into being a Client Component by adding the "use client" directive at its top. That marks the boundary: from that file downward, the component and everything it imports gets bundled and shipped to the browser, and can use hooks like useState and browser APIs that a Server Component can't.
'use client';
import { useState } from 'react';
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return ;
}
Composition: children flow one way
The rule that trips people up first: a Client Component cannot import a Server Component. Once you cross into "use client", everything imported from there is bundled for the browser, and a Server Component's code (database calls, server-only imports) can't safely live in that bundle. The escape hatch is passing Server Components down as children or props — a Client Component can render a Server Component that was handed to it by its parent, it just can't import one directly itself.
A client-side layout or modal wrapper can still show server-rendered content: render the server component higher up the tree and pass it in as `children`, rather than importing it inside the client file.
Why this needs a framework, not just React
Server Components depend on a bundler and router that understand the server/client split — deciding which modules get sent to the browser, serializing the server-rendered output into something the client can stitch together, and wiring up the network boundary between the two. Plain React (a Vite SPA, Create React App) has no server runtime and no bundler-level concept of this split, so there's no "just enable RSC" flag to flip. In practice, this means Next.js's App Router today, with other frameworks and bundlers building equivalent support over time.
Upgrading a plain React app's package.json to React 19 does not give you Server Components. RSC requires the framework layer to actually implement the server/client boundary — the React version is necessary but nowhere near sufficient.
Wrapping up
Server Components are best understood as "components that opt out of the client bundle by default," with Client Components as the deliberate exception rather than the norm. The mental flip — start server-first, add "use client" only where you need interactivity or browser APIs — is the real shift, and it only works because a framework like Next.js has built the machinery underneath React to make that boundary real.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.