Frontend / React / 24_server_components.md

React Server Components

Updated 8 interview angles 11 min read source
On this page8
  1. The mechanism: a bundler boundary, not a runtime one
  2. What a server component is
  3. Data fetching, and the waterfall that moved
  4. Where it breaks in production
  5. Where this stands in
  6. The trade not taken
  7. Related
  8. Interview angle

React Server Components

Article.

Verified 2026-08

Verified 2026-08.

Two claims about "use client" are both wrong, and almost everyone believes at least one of them:

  1. “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.
  2. “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.

text
IMPORT GRAPH                       what ships to the browser

layout.tsx        (server)
  └─ Nav.tsx      "use client"  ───┐
       ├─ icons        ─────────── │  all of this
       ├─ date-lib     ─────────── │  is now client
       └─ analytics    ─────────── ┘  JavaScript

One 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.

tsx
// 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>
  );
}
tsx
// 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”.

text
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 again

Consequences that follow directly:

  • window in a client component’s render body still crashes, because the server ran it. It belongs in useEffect or behind a mount check.
  • A client component with a heavy synchronous computation pays for it twice.
  • “Add use client to fix the SSR error” is not a fix; it is a coincidence when it appears to work.

What a server component is

tsx
// 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
tsx
<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:

tsx
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.

tsx
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

tsx
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

tsx
const user = await db.user.findUnique({ where: { id } });
return <Profile user={user} />;      // Profile is a client component

The 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

tsx
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:

tsx
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

tsx
"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.

tsx
"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:

ts
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-subrequest header 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 (renamed proxy.ts in 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.

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 children and 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.
  • “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 why window in 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. Use Promise.all for 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.