React Server Components
Article.
Verified 2026-08Verified 2026-08.
Two claims about "use client" are both wrong, and almost everyone believes at
least one of them:
- “It marks a subtree as client-side.” — no, it marks a module as an entry point into the client bundle, and the import graph is not the render tree.
- “It means the component only runs in the browser.” — no, it runs on the server too, for the initial HTML, and then again in the browser.
Get those two right and the rest of RSC follows. Get them wrong and you ship a “server-rendered” app whose entire tree is in the client bundle.
The mechanism: a bundler boundary, not a runtime one
"use client" is a directive the bundler reads. It marks the module as the
start of the client bundle, and everything that module imports, transitively,
goes with it.
IMPORT GRAPH what ships to the browser
layout.tsx (server)
└─ Nav.tsx "use client" ───┐
├─ icons ─────────── │ all of this
├─ date-lib ─────────── │ is now client
└─ analytics ─────────── ┘ JavaScriptOne directive at the top of one file pulls its whole import subtree across.
That is why a "use client" in a shared layout is expensive in a way that is
invisible in the source — nothing in date-lib says anything about clients.
The render tree is a different graph
Here is the part that surprises people: a client component can render a server
component. It just cannot import one.
// Nav.tsx — "use client"
export function Nav({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<nav>
<button onClick={() => setOpen(!open)}>Menu</button>
{open && children} {/* rendered here, but not imported */}
</nav>
);
}// page.tsx — server component
<Nav>
<ServerOnlyMenu /> {/* rendered on the server, passed as children */}
</Nav>ServerOnlyMenu runs on the server. Its output is serialized into the RSC
payload and handed to Nav as an already-rendered element. Nav never imports
it, so its code never enters the client bundle.
Composition through children is the escape hatch for the entire model, and
it is the technique that keeps a highly interactive shell from dragging the
whole app into the browser. If a client component needs server content, pass it
in; do not import it.
Client components still render on the server
"use client" does not mean “client only”. It means “also runs on the
client”.
request → server renders EVERYTHING to HTML
server components (once, discarded)
client components (once, for the HTML)
→ browser receives HTML + RSC payload
→ client components hydrate and run againConsequences that follow directly:
windowin a client component’s render body still crashes, because the server ran it. It belongs inuseEffector behind a mount check.- A client component with a heavy synchronous computation pays for it twice.
- “Add
use clientto fix the SSR error” is not a fix; it is a coincidence when it appears to work.
What a server component is
// app/posts/page.tsx — a server component, by default in the App Router
import { db } from "@/lib/db";
export default async function PostsPage() {
const posts = await db.posts.findMany(); // direct database access
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}| Server Component | Client Component | |
|---|---|---|
useState, useEffect |
no | yes |
| Event handlers | no | yes |
async component |
yes | no |
| Database, secrets | yes | no |
| Ships its own JS | no | yes |
| Runs on the server | yes | yes, for the HTML |
The last row is the one to remember. The first is the one that pays: a content-heavy route that renders markdown ships zero JavaScript for that rendering.
What crosses the boundary
Props from a server component to a client component are serialized, so they have to be serializable.
| Allowed | Not allowed |
|---|---|
| primitives, plain objects | ordinary functions |
Date, Map, Set, Promise |
class instances |
| React elements | anything closing over server state |
"use server" actions |
most symbols |
<ClientChart
data={rows} // fine
onPointClick={handleClick} // error: a function cannot be serialized
/>A function prop has to become a Server Action ("use server"), which is
serialized as a reference the client can call — or the logic moves into the
client component.
Data fetching, and the waterfall that moved
The pattern RSC replaces:
const { data: posts } = useQuery({ queryKey: ["posts"], queryFn: fetchPosts });
if (!posts) return <Spinner />;Three things shipped: the component, the query library, and the loading UI. Plus a round trip the user waits through after the bundle downloads.
export default async function PostsPage() {
const posts = await db.posts.findMany();
return <PostList posts={posts} />;
}Query libraries still own mutations, revalidation and refetch-on-focus inside client components — see TanStack Query and server state. They stop owning one-shot initial data.
Streaming with Suspense
export default function Dashboard() {
return (
<>
<Header />
<Suspense fallback={<Skeleton />}>
<SlowReport /> {/* streams in when its data resolves */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<SlowFeed />
</Suspense>
</>
);
}Each boundary streams independently, so the shell paints immediately and slow sections arrive as they finish. This is the LCP argument for RSC — the page is visible before the slowest query returns.
Where it breaks in production
"use client" creep
One shared component adds the directive, and because the import graph is transitive, a large slice of the tree crosses with it. The bundle grows and nothing in the diff explains why.
Push the directive down to the leaves that genuinely need interactivity — a
button, not the layout that contains it — and use children to pass server
content through the client boundary rather than importing it.
Audit it: if the bundle grew, look for a new "use client" above a component
that only needed a click handler.
Over-fetching straight into the payload
const user = await db.user.findUnique({ where: { id } });
return <Profile user={user} />; // Profile is a client componentThe whole row is serialized into the RSC payload, which is visible in the network tab. Password hash, internal flags, other people’s identifiers if the object came from a join.
The serialization boundary is a trust boundary. Select the fields you need, or map to an explicit DTO. Server components make it very easy to hand a database row to the browser without noticing.
A server-side waterfall with no network tab
async function Page() {
const user = await getUser(); // 120ms
const posts = await getPosts(user.id); // 200ms - genuinely dependent
const feed = await getFeed(); // 180ms - NOT dependent
// 500ms sequential
}The client waterfall you removed reappeared on the server, where there is no devtools waterfall to make it visible. Start independent fetches together:
const [user, feed] = await Promise.all([getUser(), getFeed()]);
const posts = await getPosts(user.id);Nested server components make this worse, because each level awaits before rendering its children. Fetch at the level that knows about all of it, or hoist the promise and pass it down.
Every Server Action is a public endpoint
"use server";
export async function deletePost(id: string) {
await db.post.delete({ where: { id } }); // who is calling this?
}A "use server" function compiles to an HTTP endpoint that anyone can POST to
with any arguments. It is not protected by the fact that the UI only renders
the button for admins.
Authenticate and authorize inside every action, and validate the arguments, because the types are erased at the wire. Treat each one as a route handler that happens to have nice ergonomics.
"use server";
export async function deletePost(id: string) {
const session = await auth();
if (!session) throw new Unauthorized();
const post = await db.post.findUnique({ where: { id } });
if (post.authorId !== session.userId) throw new Forbidden();
await db.post.delete({ where: { id } });
}Server-only code reachable from the client
Import a module holding an API key into a client component and the key is in the
bundle. The server-only package makes that a build failure instead of a
disclosure:
import "server-only"; // fails the build if a client module imports this
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);Put it in every module that touches a secret. It costs one line and it converts a silent leak into a broken build.
A boundary that never resolves
A <Suspense> boundary wrapping a fetch with no timeout holds the stream open
indefinitely. The user sees a skeleton forever, and the connection stays open.
Every server-side fetch inside a boundary needs a deadline — see
Timeouts, retries and backoff.
Hooks in a third-party library
Most component libraries use hooks internally, which makes them client
components whether or not they say so. Wrapping them in your own thin
"use client" module is the fix, and it keeps the boundary explicit rather than
letting an import decide it for you.
The server is attack surface
Server Components and Server Functions put a deserialiser and callable endpoints on your server, and both have shipped critical bugs:
- React2Shell, CVE-2025-55182 (2025-12-03, CVSS 10): unauthenticated remote code execution through Flight-protocol deserialisation in React’s server packages. The first patches were incomplete; the full fix is React 19.0.3, 19.1.4 or 19.2.3 and later. Any app using Server Functions was exposed whether or not its own code did anything unusual.
- CVE-2025-29927 (2025-03, Next.js): an
x-middleware-subrequestheader made Next.js skip middleware, so auth checks done only in middleware could be bypassed. Authorise in the data layer and in each Server Function, not only in middleware (renamedproxy.tsin Next.js 16).
Both reinforce the earlier point: every Server Action is a public endpoint.
Where this stands in 2026
React 19 (19.2.7 as of 2026-06) has the Server Component APIs as stable primitives, but they need a framework to wire up the runtime, the bundler and the streaming protocol.
Next.js App Router (15+) is the mainstream production answer. Other frameworks have been adding support, and bare React with a hand-rolled bundler integration remains impractical. If you are not on a framework that implements it, you are not using RSC — and most of the world is not, which is fine.
The trade not taken
RSC buys a smaller bundle and removes a round trip. What it costs is worth being straight about.
It is a re-architecture, not an incremental adoption. An existing SPA with a REST API and client-side routing does not get RSC by adding a directive; it gets it by moving to a framework that owns rendering, which changes deployment, testing and the mental model at once.
It requires a server. A static bundle on a CDN has no per-request rendering, so the whole model is unavailable — and for a marketing site or a docs site, static generation is simpler and at least as fast.
It splits every component into “which kind is this?” That question has a right answer, and it is a question that did not previously exist. On a heavily interactive product — an editor, a canvas, a live dashboard — nearly everything is a client component anyway, and RSC helps only at the shell.
The honest framing: RSC is a large win for content and data-heavy routes where
the previous shape was fetch-in-useEffect, and close to neutral for
interaction-heavy applications. Knowing which of those you are building is the
decision; the directive is just syntax.
Related
Interview angle 8
- “What is a Server Component?” — a component that runs only on the server
during the request, can be
async, can read the database directly, and ships no JavaScript of its own. Its output is serialized into the RSC payload that the client assembles alongside the client components. - “What does
"use client"actually do?” — it marks a module as an entry point into the client bundle. Everything that module imports, transitively, ships with it. It is a bundler directive about the import graph, not a runtime one about a subtree.- Follow-up: “So a client component cannot render a server component?” — it
can, it just cannot import one. Pass the server component as
childrenand it renders on the server, gets serialized, and arrives already rendered. That composition is what keeps an interactive shell from pulling the whole app client-side.
- Follow-up: “So a client component cannot render a server component?” — it
can, it just cannot import one. Pass the server component as
- “Do client components run on the server?” — yes, once, to produce the
initial HTML, and then again in the browser when they hydrate.
"use client"means “also runs on the client”, not “only”. That is whywindowin a render body still crashes. - “What can you pass across the boundary?” — anything serializable:
primitives, plain objects,
Date,Map,Set, React elements, and Server Actions. Not ordinary functions or class instances. A callback prop has to become a Server Action.- Follow-up: “Any risk in what you pass?” — the payload is visible in the network tab, so handing a whole database row to a client component publishes every column. The serialization boundary is a trust boundary; select fields or map to a DTO.
Under pressure
- “Your bundle grew and nobody added a dependency.” — a
"use client"moved up the tree, and the import graph is transitive, so everything below it crossed. Push the directive down to the leaves that need interactivity. - “Is a Server Action safe because only admins see the button?” — no. It compiles to an HTTP endpoint anyone can POST to with arbitrary arguments. Authenticate, authorize and validate inside every action.
- “You removed the client waterfall and the page is still slow.” — the
waterfall moved to the server, where there is no network tab to show it.
Sequential
awaits in nested server components serialise. UsePromise.allfor the independent ones. - “When would you not use RSC?” — an interaction-heavy app where almost
everything is a client component anyway, a static site with no server, or an
existing SPA where adopting it means a framework migration rather than a
refactor. The win is concentrated in data-heavy routes that were previously
fetching in
useEffect.