frontend / react / server_components.md

Server Components (RSC) — What Runs Where, Serialization, the "use client" Boundary

7 min read source

Server Components (RSC) — What Runs Where, Serialization, the "use client" Boundary

TL;DR

React Server Components (RSC) are components that run only on the server. They can read databases, files, and secrets; they output a serialized “RSC payload” that the client streams in and renders. They render with client components (the ones using useState/useEffect/event handlers) in the same tree, with a "use client" directive marking the boundary. RSC ship zero JS to the client for their own code and let you push data fetching into the component tree without a separate API call. The mental model: server components own data + structure; client components own interactivity. Available in Next.js App Router (the production answer today); bare React supports them but requires a framework.

Interview Q&A

Q: What is a Server Component, concretely?

A: A component that runs on the server during the request. It can be async, can await data, and renders to React elements that the server serializes into the RSC payload (a streamed format) sent to the client.

// app/posts/page.tsx (this is a Server Component by default in App Router)
import { db } from "@/lib/db";

export default async function PostsPage() {
  const posts = await db.posts.findMany();   // direct DB access
  return (
    <ul>
      {posts.map((p) => <li key={p.id}>{p.title}</li>)}
    </ul>
  );
}

Properties:

  • No useState, no useEffect, no event handlers. Server runs once per request; there’s no event loop in the same sense.
  • Can be async. Top-level await is allowed.
  • Has full Node access — DB, filesystem, env vars, internal-only services.
  • Its own JS doesn’t ship to the client. Only its rendered output.

Q: What’s the "use client" directive?

A: Marks a module as client-side — anything imported from it ships to the browser and runs there. Used at the boundary between server and client trees.

// app/components/LikeButton.tsx
"use client";

import { useState } from "react";

export default function LikeButton({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);
  return <button onClick={() => setCount((c) => c + 1)}>{count} </button>;
}

In a server component:

// app/posts/page.tsx (Server Component)
import LikeButton from "../components/LikeButton";

export default async function PostsPage() {
  const posts = await db.posts.findMany();
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>
          {p.title} <LikeButton initialCount={p.likeCount} />
        </li>
      ))}
    </ul>
  );
}

The page is a server component (no "use client"); the LikeButton is a client component. React assembles both into one tree. Only LikeButton’s JS ships to the browser.

Q: What’s serialized between server and client?

A: Props passed from server components to client components must be serializable — JSON-ish values, plus React elements, plus Server Actions. Specifically allowed:

  • Primitives (string, number, boolean, null, undefined, BigInt).
  • Plain objects / arrays of allowed values.
  • Date, Map, Set, Promise.
  • React elements (server components rendered inline).
  • Functions marked "use server" (Server Actions).

Not allowed:

  • Regular functions (would have to ship to the client — they can’t be serialized).
  • Class instances (no class metadata to reconstruct).
  • Symbols (most).
  • Anything closing over server-only state.
// Server component
<ClientChart
  data={data}                    // ok — serializable
  onPointClick={handlePoint}     // BAD — function not serializable
/>

If you need a callback prop on a client component from a server component, either:

  • Make it a Server Action ("use server").
  • Move that logic into the client component itself.

Q: When does RSC not fit?

A:

  • Highly interactive UI (an editor, a chart with hover handlers, a real-time stream) — most of the tree is client. RSC at the edges still helps for the initial data shell.
  • Static blog with no live data — SSG might be simpler.
  • Existing SPA without SSR infrastructure — moving to RSC is a re-architecture, not a sprinkling.

The killer wins for RSC:

  • Database-backed pages where the data fetching was previously REST/GraphQL + useEffect + loading state. RSC collapses all of that into a server-side await.
  • Pages where data fetching forms a waterfall — the server can parallelize cleanly without prop-drilling.
  • Reducing client bundle for content-heavy routes (a blog post page that’s 100 KB of markdown rendering ships zero JS).

Q: What does the rendered output look like over the wire?

A: The “RSC payload” is a custom streaming format — not HTML, not JSON. It’s a serialized representation of the rendered React tree that the client uses to assemble the page with its client components.

Concretely, Next.js streams two things:

  1. HTML (the SSR’d initial paint).
  2. RSC payload (the same tree’s React structure for the client to hydrate against).

The browser sees HTML immediately (good LCP); JS arrives and hydrates the client components; the RSC payload tells the client how to reconcile.

Q: How does data fetching change with RSC?

A: Old SPA pattern:

function PostsPage() {
  const { data: posts } = useQuery({ queryKey: ["posts"], queryFn: fetchPosts });
  if (!posts) return <Spinner />;
  return <PostList posts={posts} />;
}

Three things had to ship: the component code, TanStack Query, the loading state UI. RSC version:

export default async function PostsPage() {
  const posts = await db.posts.findMany();
  return <PostList posts={posts} />;
}

The fetch happens server-side; client sees rendered HTML. Bundle drops; latency drops (no client round trip). TanStack Query / SWR are still useful for mutations and revalidation in client components, but you stop using them for one-shot initial data.

Q: How do RSC and Suspense interact?

A: A <Suspense> boundary around a server component lets that subtree stream independently. The shell renders immediately with fallbacks; each suspended boundary streams in as its data resolves.

export default function Dashboard() {
  return (
    <>
      <Header />
      <Suspense fallback={<Skeleton />}>
        <SlowDataSection />     {/* server component awaiting data */}
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <AnotherSlowSection />
      </Suspense>
    </>
  );
}

The user sees the header + skeletons immediately, then each section pops in. This is streaming SSR in the App Router model — the bottom of the page can render before the top finishes (more or less).

Q: Server vs Client — quick reference.

A:

Server Component Client Component ("use client")
Hooks (useState, useEffect) no yes
Event handlers (onClick, etc.) no yes
Browser APIs (window, document) no (in useEffect)
async/await at top level yes (in components; effects can be async-wrapped)
Access DB, fs, env vars yes (those would leak to browser)
Renders to RSC payload + HTML DOM (after hydration)
Ships JS? no yes

A client component can render a server component as children (passed as a prop) — that’s how interactivity wraps data fetching. A client component cannot directly import a server component (would force the server code into the client bundle).

Q: Where are RSCs production-ready?

A:

  • Next.js App Router — the canonical implementation, used in production by many large sites since 2023.
  • Remix Vite + SPA + soon-Server-modules — overlapping but not identical model.
  • Bare React — Server Component APIs exist (react-server), but there’s no out-of-the-box framework that wires the runtime, bundler, and streaming together. Adoption is via a framework.

If you’re not on Next/Remix, you’re not using RSC. That’s fine — most of the world isn’t.

Gotchas / edge cases

  • Importing a server-only module into a client component is a hard error in Next.js (and a runtime danger elsewhere). Use the server-only package to fail loudly at the boundary.
  • Passing functions as props from server to client silently breaks serialization. Move to Server Actions.
  • useState in a server component file = build error. Add "use client" or move to a sub-component.
  • Auth context — checking auth in a server component reads cookies/headers; checking in a client component reads a context provider. The bridge is usually: server reads cookies, passes user data as a prop to the client.
  • Third-party libraries that use hooks internally are client components by default — wrap them in your own client wrapper if you want to use them in a server tree.
  • Streaming + slow boundary — a Suspense boundary that hangs forever blocks the stream from completing. Set timeouts in the data fetcher.
  • Caching — Next.js App Router has its own fetch cache layer (fetch(url, { next: { revalidate: 60 } })); behavior is bound to the framework, not raw React.

What a senior is expected to say

  • “RSC are server-only components — they fetch data, render, and produce a serialized payload. They ship zero JS for themselves; client components handle interactivity via "use client".”
  • “Props from server to client must be serializable — no functions (use Server Actions), no class instances. The "use client" boundary is where the JS bundle starts.”
  • “RSC replaces a lot of useQuery + loading-state boilerplate for initial data; TanStack Query still owns mutations and refetch-on-focus inside client components.”
  • “Production answer today is Next.js App Router. Bare React’s RSC primitives exist but need framework runtime + bundler integration to be usable.”
  • “Streaming + Suspense lets the shell paint immediately and each suspended subtree stream in — the dashboard’s header doesn’t wait for the slow report.”

Cross-references

Further reading