Web / React

React Server Components Explained: The Boundary, Serialization, and Data Flow

/14 min read

React Server Components (RSC) are a component type that renders only on the server — at build time or during a request — and is never sent to the browser as code. The defining property is zero JavaScript shipped for that component: the client receives its rendered output, not its implementation, so heavy libraries and data-access code stay off the wire entirely.

The Confusion Worth Clearing Up First

Three overlapping terms get mixed constantly. Server-side rendering (SSR) means rendering a React tree to HTML on the server so the first paint is fast — but the component code still ships, because the browser must hydrate it to make it interactive. Client components are the classic React model: code runs in the browser, state and effects work, everything is in the bundle.

Server Components are a third thing, and they compose with SSR rather than replacing it. A Server Component can be rendered to HTML on the server as part of SSR, and its output can also be streamed to the client as a serialized payload. The React docs are explicit about one detail that trips everyone up: there is no directive for Server Components. Files are Server Components by default in an RSC-enabled framework; "use client" opts into the browser, and "use server" is unrelated — it marks Server Functions.

The Two-Component Model

An application written this way has two kinds of modules, and the boundary between them is drawn per file by the use client directive. Server Components own the data-heavy, static half: fetching from the database directly, rendering lists, layouts, and anything that would otherwise require an API route. Client Components own the interactive half: state, effects, event handlers, browser APIs.

Composition flows downward. A Server Component imports a Client Component and renders it, passing data as props. Inside the boundary, that Client Component behaves like ordinary React — with one constraint that shapes the entire architecture: props must serialize. What crosses the boundary is plain data and references, never live objects.

This inverts the usual waterfall. Instead of shipping a bundle that fetches data in useEffect after first paint — the pattern React's own docs replace with Server Components — the data is read during render, on the server, before anything reaches the browser. React cites the example of rendering markdown: the client-side approach drags roughly 75KB gzipped of libraries (a markdown parser and an HTML sanitizer) into the bundle plus a second round trip for content. The Server Component version does that work on the server and ships only the resulting HTML — the parser and sanitizer never cross the wire.

What Actually Crosses the Boundary

Serialization is the rule that makes the architecture work, and it is where most first attempts fail. Props passed from a Server Component to a Client Component are encoded into the RSC payload: plain data — strings, numbers, booleans, null — plain objects and arrays, plus a few supported types such as Dates and even Promises, which lets you start a fetch on the server and await it on the client with the use hook.

What does not cross: functions defined in a Server Component, class instances, DOM nodes, and event handlers. The single deliberate exception is a Server Function reference — a pointer to a function that will run on the server when invoked, not the function itself. That mechanism is what lets a Server Component hand a click handler to a Client Component without shipping the handler's body.

// Server Component: this file never enters the browser bundle.
async function Dashboard({ userId }: { userId: string }) {
  const user = await db.users.get(userId);

  // Low-priority data: start now, resolve while the page streams.
  const activityPromise = db.activity.list(userId, 50);

  return (
    <section>
      <h1>{user.name}</h1>
      <Suspense fallback={<ActivitySkeleton />}>
        <ActivityFeed promise={activityPromise} />
      </Suspense>
    </section>
  );
}

The snippet shows the other Server Component superpower: await in render. Async components suspend while the promise resolves, and React coordinates that with Suspense so the shell streams first and the slow region fills in later. The activity promise is deliberately not awaited — it is created on the server, passed as a Promise prop, and resolved on the client, so the below-the-fold content never blocks the headline.

RSC vs SSR vs Client: The Comparison That Matters

The three models solve different problems, and the table below is the decision aid. Read it as dimensions of one rendering pipeline rather than three competing frameworks.

DimensionClient componentSSRServer Component
Executes inBrowserServer, then browserServer or build
JS shippedFull componentFull component (hydration)Zero
InteractivityYesAfter hydrationNone — compose below
Data accessfetch + API routesServer data layerDirect await in render
State & effectsYesYesNo
Heavy librariesIn the bundleIn the bundleStay on server

Verdict:SSR and RSC are complements, not rivals — a framework typically server-renders the RSC tree to HTML for first paint, then streams the payload for progressive hydration of the client islands. The real decision is per component: interactive and stateful → client; data-fetching and compositional → server. The failure mode is pushing everything client-side "to be safe," which re-imports the bundle bloat and fetch waterfalls RSC exists to remove.

Data Fetching Without Waterfalls

In the classic model, a page renders, an effect fires, a fetch resolves, a second render happens — and nested components repeat the pattern, producing request waterfalls where each level waits for the one above. React's docs show the pattern plainly: a note component fetches, then renders an author component that fetches again, after the first render.

With Server Components, fetching happens during render, and sibling work can start concurrently because the promises exist before the JSX is returned. For interactive apps, the same principle applies on the client with React's transition and Suspense APIs: start the transition, keep the old UI visible, suspend on the result, swap when it is ready.

Note what this removes: the API route that existed only to proxy a database read. The Server Component reads the data layer directly, which shrinks the surface area of the application — fewer endpoints, one serialization step instead of fetch-then-parse, and no client-visible query shape.

Mutations: Server Functions and Actions

Read paths become Server Components; write paths become Server Functions. Defining an async function in a module marked with use server gives the bundler a reference the client can call. When the client invokes it, React sends a request, the function executes on the server with full access to your data layer, and the result comes back. The function body never ships.

// actions.ts — "use server" marks every exported function
// as a Server Function: it executes on the server when called.
"use server";

export async function createInvoice(formData: FormData) {
  const raw = formData.get("amount");
  const amount = Number(raw);

  if (!Number.isFinite(amount) || amount <= 0) {
    return { error: "Amount must be a positive number" };
  }

  const invoice = await db.invoices.create({ amount });
  redirect("/invoices/" + invoice.id);
}

Passed as the action prop of a form, a Server Function gets progressive enhancement for free: the form submits before hydration, and useActionState exposes the pending state and the last returned response — which is how the error path above becomes UI. Outside a form, wrap the call in a transition to get isPending without inventing your own loading flag.

"use client";

// Receives plain data and a Server Function reference from a
// Server Component. Nothing else crosses the boundary.
export function SubscribeButton({
  planId,
  onSubscribe,
}: {
  planId: string;
  onSubscribe: (planId: string) => Promise<void>;
}) {
  const [pending, startTransition] = useTransition();

  return (
    <button
      disabled={pending}
      onClick={() => startTransition(() => onSubscribe(planId))}
    >
      {pending ? "Subscribing..." : "Subscribe"}
    </button>
  );
}

That client component is the shape of the boundary in miniature: a serializable plan identifier, a reference to a server-side function, and local pending state. No data-access code, no business logic — just the interactive surface. Everything interesting happens on the other side of the wire.

How Next.js App Router Maps Onto This

Next.js implements the model with file-system conventions. Route files under app/ are Server Components by default; adding use client at the top of a file opts that module and its imports into the client bundle. Layouts, pages, and data loaders run on the server; interactive leaves opt in individually.

Three practical consequences. First, the boundary follows imports: mark one component as client, and everything it imports becomes client code too — so keep the directive at the leaves of your tree, not at the layout. Second, secrets and database drivers belong in server-only modules; importing them from a client file either bundles them (a leak) or fails the build if you mark the module with server-only. Third, Server Components can render providers defined in client modules — create the context in a use client file, then wrap children with it from a Server Component layout, because Server Components cannot create context themselves.

Common Mistakes at the Boundary

  • Assuming functions serialize: Passing a callback defined in a Server Component to a Client Component fails. Pass a Server Function reference or move the callback into the client file.
  • Marking too much as client: A use client at the root of a page pulls its entire import graph into the bundle. Directives belong on interactive leaves.
  • State where data belongs: Mirroring fetched server data into client state just to render it duplicates the payload and reintroduces the waterfall. Render it on the server; lift only genuinely interactive state across.
  • Using use client as an SSR fix: The directive does not make rendering faster — it changes where code ships. Hydration cost is bundle cost; adding client components to fix a performance problem usually deepens it.
  • Forgetting that Server Components have no lifecycle: No hooks, no effects, no handlers. If a component needs useState, it is a Client Component — split it instead of reaching for workarounds.

When Not to Reach for RSC

Server Components pay off where content and data dominate: marketing pages, documentation, feeds, dashboards with heavy queries, anything where the bundle is the bottleneck. They help less for interaction-dense surfaces — editors, canvases, drag-and-drop builders, anything with optimistic UI and local state everywhere — because most of that code has to ship to the browser anyway.

The pragmatic rule: start every new component as a Server Component and let interactivity pull code into the client, one leaf at a time. The boundary is cheap to move downward and expensive to discover you drew too high.

Conclusion

React Server Components are best understood as a rendering contract: code that runs only on the server ships as output, code that runs in the browser ships as code, and the boundary between them moves data — not implementations. Once serialization and the use client boundary are internalized, the architecture stops feeling exotic and starts feeling like the obvious way to keep data access, heavy libraries, and secrets out of the browser while keeping interactivity exactly where users touch it.

The framework does the hard part — the payload encoding, the streaming, the progressive enhancement. Your job is simply drawing the boundary well: server by default, client at the leaves, serializable across.

Key Takeaways

  • Server Components render on the server and ship zero JavaScript — the browser receives output, not implementation.
  • There is no directive for Server Components; "use client" opts a file into the browser, and "use server" marks Server Functions.
  • Only serializable props cross the boundary: plain data, Dates, Promises — never functions, class instances, or event handlers (Server Function references excepted).
  • Fetching belongs in render: await directly in a Server Component, stream slow regions with Suspense, and pass promises to the client with the use hook.
  • SSR and RSC compose rather than compete; the real decision is per component — interactive → client, data and composition → server.

FAQ

What is a React Server Component?

A component type that renders only on the server — at build time or per request — and is never delivered to the browser as code. The client gets its rendered output, so the component contributes zero JavaScript to the bundle while keeping direct access to your data layer.

Are Server Components the same as SSR?

No. SSR renders React to HTML on the server but still ships the component code so the browser can hydrate it. Server Components ship no code at all. They compose: a framework typically server-renders the RSC tree for first paint and streams the payload for the interactive client islands.

Why does "use server" not mean Server Component?

Because Server Components need no directive — files are server components by default in RSC-enabled frameworks, and use client opts out. The use server directive has a different job: it marks exported functions as Server Functions that execute on the server when the client calls them.

What can I pass from a Server Component to a Client Component?

Serializable data: strings, numbers, booleans, null, plain objects and arrays, Dates, and Promises you can resolve on the client with the use hook. Functions and class instances do not cross; Server Function references do, because they resolve to server-side code rather than shipping it.

When should a component be a Client Component?

When it uses state, effects, event handlers, or browser APIs — and otherwise should stay a Server Component. Keep the directive on interactive leaves so the client bundle contains only the interactivity users actually touch, not the data-fetching and layout around it.

References

  1. React. "Server Components." react.dev/reference/rsc/server-components
  2. React. "The use client Directive." react.dev/reference/rsc/use-client
  3. React. "Server Functions." react.dev/reference/rsc/server-functions
  4. Next.js. "Server and Client Components." nextjs.org — Server and Client Components
  5. React. RFC 188: React Server Components. github.com/reactjs/rfcs/pull/188
  6. React. "React 19" — Server Components and Actions announced stable. react.dev/blog — React 19
Summarize with AI
Page