Skip to main content

React Server Components

What you'll learn

  • The one question RSC answers: where does each component run?
  • Server components: async, data-direct, zero JavaScript shipped, and their hard limits
  • Client components: the React you know, now explicitly marked
  • The three integration rules: serializable props, the import boundary, no client re-render
  • The composition pattern that lets client components wrap server-rendered content
  • What you actually gain: smaller bundles, direct data access, no fetch waterfalls

Everything in the last two chapters shared one assumption: your components run twice, once on the server for HTML, once in the browser for hydration. React Server Components (RSC) throws out that assumption. It asks a sharper question: what if some components only ever run on the server, and never ship to the browser at all?

The two kinds of components

In an RSC app, every component is one of two kinds:

Jargon: "server component". A component that runs only on the server. It renders once per request, its output is sent to the browser as a description of UI, and zero of its JavaScript is ever downloaded. This is the default kind in an RSC app.

Jargon: "client component". A component marked with 'use client' at the top of its file. The React you already know: its code is bundled, shipped to the browser, hydrated, stateful, interactive. It usually also renders on the server for initial HTML, but it must be able to run in the browser.

A server component looks deceptively normal, until you notice it can do things no browser component could:

// No directive needed — server is the default in an RSC app.
import { db } from './database';

export default async function ProductPage({ id }) {
const product = await db.products.find(id); // yes, await, inside a component
const reviews = await db.reviews.forProduct(id);

return (
<main>
<h1>{product.name}</h1>
<p>{product.description}</p>
<ul>
{reviews.map((r) => (
<li key={r.id}>{r.text}</li>
))}
</ul>
</main>
);
}

What happens:

  1. A request comes in; the server calls ProductPage, and it's an async function, so rendering awaits the database directly. No API layer, no fetch, no loading state in the component.
  2. The output, a plain UI description of <main>, <h1>, list items, is sent to the browser (in the streamed row format of the next chapter).
  3. The browser displays it. ProductPage's code, the db import, the query logic: none of it exists in the client bundle. It can't re-render and can't hydrate, because it isn't there.

Those powers have mirror-image limits. A server component has no re-render (it ran once, for a request) and no browser (it never sees one). So none of these exist there: useState, useEffect, useRef, event handlers, window. State implies change over time; effects imply a live browser. A server component is a one-shot function from data to UI description.

A client component is marked explicitly:

'use client';

import { useState } from 'react';

export function AddToCartButton({ productId, price }) {
const [added, setAdded] = useState(false);

return (
<button onClick={() => setAdded(true)} disabled={added}>
{added ? 'In cart ✓' : `Add to cart — $${price}`}
</button>
);
}

What happens: this file, and everything it imports, goes into the browser bundle. The button hydrates, holds state, responds to clicks. All of Part 3 applies unchanged.

Jargon: "'use client'". A directive at the very top of a file marking the boundary: this module and its imports are client code. It doesn't mean "only run on the client"; it means "this code must be shippable to the client."

The mental picture: skeleton with holes

Here's the image to hold. The server renders your page into a static skeleton, all the server components expanded into plain UI. Wherever the tree hits a client component, the server can't expand it; instead it leaves a hole, marked with a reference: "client component AddToCartButton goes here, with these props." The browser receives the skeleton plus references, downloads the referenced client code, and fills each hole with a live, interactive island.

Server components are the static terrain. Client components are the interactive islands embedded in it.

The integration rules (where learners stumble)

Rule 1: props crossing the boundary must be serializable

When a server component renders a client component, the props travel from server to browser as data. They get written into a stream and reassembled on the other side. So they must be serializable: strings, numbers, booleans, plain objects and arrays, Dates, Maps and Sets, fine. Functions are not data, behavior can't be written down and shipped. (One deliberate exception: server actions, functions created on the server specifically to be called back, next chapter.)

import { db } from './database';
import { AddToCartButton } from './AddToCartButton';
import { ProductDetails } from './ProductDetails';

export default async function ProductPage({ id }) {
const product = await db.products.find(id);

return (
<main>
<ProductDetails product={product} /> {/* server to server: anything goes */}
<AddToCartButton
productId={product.id} {/* server to client: must be data */}
price={product.price}
/>
</main>
);
}

What happens: ProductDetails is also a server component, passing it the rich product object is fine, because nothing crosses a wire; it's one function calling another on the same machine. But AddToCartButton is a client component: productId and price get serialized into the stream. Passing an onAdded callback, or the whole product if it carried methods, would fail, you can't serialize behavior.

Rule 2: client components can't import server components: but can receive them

Inside a client component's file, every import joins the client bundle. So this is impossible:

'use client';
// ❌ This would pull a server-only module (db access!) into the browser bundle.
import { ProductDetails } from './ProductDetails';

But a client component can receive server-rendered content through props, most usefully children:

'use client';

import { useState } from 'react';

export function Collapsible({ title, children }) {
const [open, setOpen] = useState(true);

return (
<section>
<button onClick={() => setOpen(!open)}>
{open ? '▼' : '▶'} {title}
</button>
{open && <div>{children}</div>}
</section>
);
}
import { db } from './database';
import { Collapsible } from './Collapsible';
import { ProductDetails } from './ProductDetails';

export default async function ProductPage({ id }) {
const product = await db.products.find(id);

return (
<Collapsible title="Details">
<ProductDetails product={product} />
</Collapsible>
);
}

What happens:

  1. ProductPage (server) renders ProductDetails (server) into a finished UI description.
  2. That finished output, not the component, not its code, is passed as children into Collapsible (client).
  3. Collapsible ships to the browser, hydrates, and toggles a box whose contents were computed entirely on the server.

Jargon: "the composition pattern". Passing server-rendered UI into a client component via children or another prop, instead of importing the server component. The client component wraps content it neither rendered nor has the code for.

This is the single most useful RSC habit: interactivity as a thin shell around server-computed content.

Rule 3: server components never re-render on the client

No state, no effects, so nothing triggers a re-render, and there's no code to re-render. If the data is stale, the only fix is to ask the server again: the framework re-requests this route or section, the server re-runs the server components with fresh data, and the updated description streams back and gets reconciled into the page. Router navigations and framework "refresh" APIs are how you express this. Think of server components as a query result, not a living widget.

What you gain

  • Data access without an API layer. The component that needs the data is the function that queries the database. No REST endpoint, no client cache keys, no serialization hand-off for internal pages.
  • Heavy dependencies stay off the bundle. A markdown parser, a syntax highlighter, a date library used only to render, in an RSC they run on the server and ship zero bytes. In the old world, everything any component imported landed in the browser bundle.
  • No fetch waterfalls. The classic pattern, render, useEffect, fetch, setState, re-render, child repeats it, chains requests across round trips. Server components await right next to the database and stream progressively (next chapter). The waterfall collapses into the server's own timeline.

Diagram

Rendered diagram (PNG hi-res):

rendered diagram

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: "Server component" means SSR. Reality: SSR renders client components to HTML once for first paint, then hydrates them. Server components never ship or hydrate at all. The features compose, but they're different axes.
  • Misconception: 'use client' means "runs only in the browser." Reality: it means "shippable to the browser." Client components usually also render on the server for initial HTML.
  • Misconception: I can pass an event handler from a server component into a client component. Reality: functions can't cross the wire, except server actions (next chapter), which are references to server code, not shipped code.
  • Misconception: Client components are a second-class escape hatch. Reality: they're where all interactivity lives. RSC's bet is that most of a typical page is not interactive, so most of it shouldn't ship JS.
  • Misconception: If a client component has children, those children become client-rendered too. Reality: children passed as props arrive already rendered by the server, the client component just places them.
  • Misconception: Server components update when server data changes. Reality: they're a one-shot render per request; "live" data means re-requesting from the server.

Why it works this way

  • Most UI isn't interactive. Headers, articles, product descriptions, footers, the bulk of a page is render-once content. Making the default component free to the user (zero JS) flips the old default where everything paid bundle tax.
  • The boundary is where the money is. Serializable-only props at the server↔client seam are what let the server stay authoritative: behavior lives on one side, data crosses.
  • Composition, not configuration. The children-prop pattern means no new API is needed to mix the two kinds, the oldest React feature does the job.
  • One direction of imports. Client→server imports would make the bundle graph depend on code that can't run in the browser. Server→client references can be expressed as data; the reverse can't.

Try it yourself

  1. In a framework with RSC (a fresh Next.js App Router project works), make an async page component that awaits a setTimeout promise or reads a file, then renders the result. Check the network tab: find the streamed response, and confirm no code for that component arrives in the browser's JS bundles.
  2. Add 'use client' to that page and try to keep the await db-style logic. Expected: errors or broken behavior, you just converted a zero-cost component into shipped code. Convert back.
  3. Build the Collapsible + server-children example. In DevTools, inspect the bundle: confirm ProductDetails's code isn't in it, yet its output appears in the page and toggles.
  4. Pass a function prop from a server component into a client component. Expected: a serialization error telling you functions can't cross the boundary. Now replace it with plain data derived on the server.

Recap

  • RSC splits components by where they run: server components (default) render once per request and ship zero JS; client components ('use client') are bundled, hydrated, interactive.
  • Server components can be async and await the database directly, but have no state, effects, refs, handlers, or browser APIs.
  • Props crossing server → client must be serializable data; no functions (except server actions, next chapter).
  • Client components can't import server components, but can receive their rendered output as children/props, the composition pattern.
  • Server components never re-render in the browser; fresh data means re-requesting from the server.
  • Payoff: direct data access without APIs, heavy deps kept off the bundle, fetch waterfalls collapsed server-side.

Next

The RSC wire format: streaming UI as data →