Skip to content
yutils

How React Server Components Actually Work

Server vs client component boundary, the 'use client' directive, RSC payload (not HTML, not JSON), streaming render, why props serialize but functions don't, and the bundle reduction RSC actually delivers.

~10 min read

React Server Components — 2021 RFC, mainstream once the 2023 Next.js App Router made them the default. The name is confusing — "render on the server" is different from SSR. RSC means "a component whose code isn't sent to the client". This guide covers the server/client boundary, the RSC payload format, and props serialization traps.

SSR vs RSC — Different Things

SSR (Server-Side Rendering, ~2014):
  1. Server renders HTML
  2. Browser receives HTML (TTFB)
  3. JS bundle downloads (all component code)
  4. Hydration — React attaches to server HTML
  → All component code still shipped to client

RSC (React Server Components, 2021+):
  1. Server renders the React tree → RSC payload (JSON-like)
  2. Client updates DOM from the payload
  → Server Component code never reaches the client
  → JS bundle shrinks (server-only code excluded)

Difference:
  SSR = "make HTML on the server" (every component's JS still ships)
  RSC = "components that only run on the server" (their code stays there)

Server vs Client Boundary

// app/page.tsx — server component (Next.js App Router default)
import {db} from "@/lib/db";

export default async function Page() {
  const posts = await db.posts.findAll();  // server-only — DB call
  return <PostList posts={posts} />;
}

// components/PostList.tsx — server component (inherits server context)
export function PostList({posts}: {posts: Post[]}) {
  return (
    <ul>
      {posts.map(p => <PostCard key={p.id} post={p} />)}
    </ul>
  );
}

// components/Counter.tsx — client component (explicit)
"use client";
import {useState} from "react";

export function Counter() {
  const [n, setN] = useState(0);  // uses useState → must be client
  return <button onClick={() => setN(n+1)}>{n}</button>;
}

Rules:
- A file with "use client" directive = client component
- Modules imported by that file also enter the client bundle
- Server components can import client components (not the reverse directly)
- But passing values via props server → client is fine

What "use client" Really Means

Wrong mental model:
  "use client" = "this component renders on the client"
                 → actually it renders on the server too (to produce SSR HTML)

Accurate:
  "use client" = "client boundary starts at this module"
                 → this file's code enters the client bundle
                 → child components default to client
                 → client-only APIs available (useState, useEffect, event handlers)

→ "use client" marks a boundary. Its position in the tree matters.

Recommended pattern:
  Page (server) → ServerComponentA (server) → LeafInteractive (client)

  Push the client boundary close to the leaf → minimize bundle.

The RSC Payload Format

The server doesn't send HTML — it sends an RSC payload (text format).

Example (simplified):
  1:I["@/components/PostList","default"]
  2:I["@/components/Counter","default"]
  M0:["$","ul",null,{"children":[
    ["$","li",null,{"children":["Post 1"]}],
    ["$","li",null,{"children":["Post 2"]}]
  ]}]
  M1:["$","$L2",null,{}]
  J0:[M0, M1]

Properties:
- JSON-like but React-aware
- I (import) line: references client components
- M (model) line: React tree
- Only serializable props (no functions, no exact Date/Map)
- Streaming — payload arrives in chunks; browser renders incrementally

vs HTML:
- HTML: represents only the initial paint; React then needs to "re-render"
- RSC payload: enables incremental updates — "refresh just this subtree"

Props Serialization Traps

When passing props server → client:

✓ OK:
  - primitives (string, number, boolean, null, undefined)
  - plain objects / arrays
  - Date (Next 15+)
  - Promise (server can pass a non-awaited promise to stream)

✗ Not allowed:
  - functions (can't serialize closures)
  - class instances (prototypes lost)
  - Map / Set (depends on framework version)
  - DOM nodes, React elements

Common trap:
  // ❌ doesn't work
  <Button onClick={() => fetchData()} />   ← function prop
  // → "Functions cannot be passed directly to Client Components"

  // ✓ Fix — Server Action
  async function handleClick() {
    "use server";
    await fetchData();
  }
  <Button action={handleClick} />

Server Action — Server Functions as Props

// app/actions.ts
"use server";

export async function createPost(formData: FormData) {
  await db.posts.create({title: formData.get("title")});
  revalidatePath("/posts");
}

// app/page.tsx
import {createPost} from "./actions";

export default function Page() {
  return (
    <form action={createPost}>   ← server action as the form action
      <input name="title" />
      <button type="submit">Create</button>
    </form>
  );
}

→ "use server" functions become auto-generated endpoints.
   On submit, the client POSTs that endpoint → runs on the server.
   Call server logic without hand-writing an API.

Note: "use server" is also a module-level directive. Every export in
the file becomes a server action.

What RSC Actually Buys

MetricSSR onlyRSC
Initial JS bundle3 MB (all components)1 MB (client only)
Library cost (markdown parser, ...)Downloaded by clientServer only → 0 client cost
DB queryVia API endpointDirectly in the component
SEOOK (SSR HTML)OK + less JS

Common Pitfalls

  • "use client" too high — at the Page level everything becomes client → RSC win = 0. Push it near leaves.
  • Using hooks in server components — useState / useEffect cause build errors. Move to the client.
  • Passing function props — error. Use Server Actions, or define inside a client component.
  • Env var leaks — server-component process.env.DB_PASSWORD accidentally flowing into a client prop ends up in the RSC payload → leaked.
  • Same component used both ways — separate server/client exports get awkward. Maintain clear boundaries.

Wrap-up

RSC at its core — components whose code doesn't reach the client. Render on the server, exclude from the client bundle. SSR was "HTML in advance"; RSC says "some components only ever live on the server".

Practical: default to server, only leaves are "use client". Call DB / external APIs directly in server components (no API layer needed). Use Server Actions for form / mutation flows. Heavy libraries (markdown parser, DB driver) stay on the server → much smaller bundles.

Back to guides