Command Palette

Search for a command to run...

Hectal
PHASE 20Advanced React 19+ ~27 min· topic 2 of 7

Topic 20.2

React Server Components

In one line

React Server Components run only on the server (at build time or per request), can be async and read data directly, and send their result to the browser instead of their code; "use client" marks where client components, which have state and event handlers, begin.

Think of it like this

A restaurant kitchen and the dining table. The kitchen (the server) has the fridge, the knives and the secret recipes; it cooks and sends out finished plates. The table (the browser) gets the plate, plus a few tools you use while eating: salt, a fork, a sauce you pour yourself. Nobody ships the whole kitchen to your table. Server Components are the cooking done in the kitchen; Client Components are the tools on the table.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Server Component
A component that runs only on the server; its code never reaches the browser, only what it renders.
Client Component
A normal interactive component that runs in the browser and can use state, effects and event handlers.
"use client"
A directive at the top of a file that marks it, and everything it imports, as client code.
Boundary
The line in the module graph where server code stops and client code starts.
RSC payload
The serialized result of rendering server components, sent to the browser to build or update the page.
Serializable
Can be turned into text and rebuilt on the other side: data yes, functions no.
Bundle
The JavaScript files the browser downloads to run your app.
Module graph
The tree of files formed by following every import from an entry file.

Step by step

01Start with a server component

In a Next.js App Router project, app/page.tsx is a server component unless it says otherwise. It can be async and talk to the database directly.

app/posts/[id]/page.tsxwhole filetsx
import { db } from "@/lib/db";
import LikeButton from "./like-button";

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const post = await db.post.findUnique({ where: { id } }); // runs on the server only
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <LikeButton postId={post.id} initialLikes={post.likes} />
    </article>
  );
}

02Add an interactive island with "use client"

The like button needs state and a click handler, so it lives in its own file that starts with "use client". The page passes it plain data (an id and a number).

app/posts/[id]/like-button.tsxwhole filetsx
"use client";
import { useState } from "react";

export default function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);
  return <button onClick={() => setLikes(likes + 1)}>Like ({likes})</button>;
}

03What runs where

On the server, React renders Page (running its query) and records LikeButton as a reference with its props. The server also pre-renders the client component to HTML for the first load. In the browser, only LikeButton's code is downloaded and hydrated.

What runs wherediagram
Rendering diagram…

04Pass server UI into client components as children

A client Tabs or Modal can't import a server component, but a server parent can pass one in as children. It is rendered on the server, and the client component only places the result.

app/page.tsxwhole filetsx
import Modal from "./ui/modal"; // "use client"
import Cart from "./ui/cart";   // server component, reads the database

export default function Page() {
  return (
    <Modal>
      <Cart />
    </Modal>
  );
}

05Keep server-only code out of the client

Mark modules that must never reach the browser (database clients, secret-using helpers) with import "server-only". Importing such a module from a client component then fails the build instead of leaking. The client-only package does the reverse for code that touches window.

lib/data.tswhole filets
import "server-only";

export async function getData() {
  const res = await fetch("https://api.example.com/data", {
    headers: { authorization: process.env.API_KEY! },
  });
  return res.json();
}

Try it yourself

  1. 1

    Pass a function and watch it vanish

    In the mini RSC, give LikeButton a prop onLike={() => console.log("liked")} inside Post. Predict the payload, then run. The function is missing from the JSON. A real RSC framework throws an error instead of dropping it silently (see Break it below).

  2. 2

    Move the state to the server

    Add useState to Comments (a server component in the model). In the model it still runs because it's only a simulation, but in Next.js you'd get a build error saying the hook only works in a Client Component. Which file would you add "use client" to instead?

  3. 3

    Count the client code

    List which functions in the mini RSC would be in the browser bundle in a real app (LikeButton, Collapsible) and which never leave the server (Post, Comments, db, query).

Code & diagrams

A mini RSC: server components, client holes and the payload New tab

A teaching model, not the real RSC format: `toPayload` plays the server (it awaits async components and leaves a `client` hole with props for each client component) and `fromPayload` plays the browser. Notice `Post` and `Comments` never appear in the payload, only their output, and `Comments` ran on the server even though the collapsible starts closed.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

{"tag":"article","children":[{"tag":"h2","children":"Hello RSC"},{"client":"LikeButton","props":{"likes":3}},{"client":"Collapsible","props":{"title":"comments"},"children":{"tag":"ul","children":[{"tag":"li","children":"Nice!"},{"tag":"li","children":"Thanks"}]}}]}
Why functions can't cross the boundary New tab

JSON silently drops the function and turns the `Date` into a string. React's real serializer keeps `Date` as a `Date`, but throws the error printed here (copied from React's server code) when a server component passes a function to a client component.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

sent keys: [ 'name', 'price', 'tags', 'addedAt', 'formatPrice' ]
received keys: [ 'name', 'price', 'tags', 'addedAt' ]
addedAt: object -> string
Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". Or maybe you meant to call this function rather than return it.
"use client" marks a boundary in the module graphdiagram
Rendering diagram…
A server component passing children into a client componenttsx
// app/ui/collapsible.tsx
"use client";
import { useState } from "react";

export default function Collapsible({ title, children }: { title: string; children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <section>
      <button onClick={() => setOpen(!open)}>{open ? "Hide" : "Show"} {title}</button>
      {open && children}
    </section>
  );
}

// app/posts/[id]/page.tsx (server component)
import Collapsible from "@/app/ui/collapsible";
import Comments from "./comments"; // async server component

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return (
    <Collapsible title="comments">
      <Comments postId={id} />
    </Collapsible>
  );
}

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Render an async component in the browser

Take the async Comments component and render it on the client: createRoot(root).render(<Comments />). In Next.js the same happens when you add "use client" to a file with an async component.

terminal
$ Console
── what you'll see ──
<Comments> is an async Client Component. Only Server Components can be async at the moment. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.
Uncaught Error: An unknown Component is an async Client Component. Only Server Components can be async at the moment. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.

Break #2

Pass an event handler from a server component

In a Next.js server component, write <LikeButton onLike={() => saveLike(id)} /> where LikeButton is a client component.

terminal
$ next dev (server error, abbreviated)
── what you'll see ──
Error: Event handlers cannot be passed to Client Component props.
…
If you need interactivity, consider converting part of this to a Client Component.

Myth vs fact

Myth

Server Components are the same as server-side rendering.

Fact

SSR turns components (including client ones) into HTML for the first load. RSC is a component type that runs only on the server and produces a payload, used on first load and on every later navigation. They work together but are different things; RSC can even run at build time with no server at request time.

Myth

"use client" means the component only runs in the browser.

Fact

Client components are still pre-rendered to HTML on the server for the first load, then hydrated. "use client" means "this code is also shipped to and runs in the browser".

Myth

You should add "use client" to every interactive component.

Fact

Only at the boundary. Everything imported from a client file is already client code. Extra directives are harmless but add noise.

Myth

Server Components replace client state.

Fact

They replace client-side data fetching for many cases. Interactive state (inputs, menus, optimistic UI) still lives in client components.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Directives ("use client", "use server") are read by the bundler, not by React at runtime. The framework's bundler builds two module graphs and swaps each client module, as seen from the server, for a reference { id, name, chunks }. That's why RSC needs framework/bundler support and isn't something you add to a Vite SPA by importing a package.

  • ▸

    Server components can pass a promise to a client component without awaiting it: <Comments commentsPromise={getComments()} />, then the client calls use(commentsPromise) inside <Suspense>. The server starts the work early and streams the resolved value later: render-as-you-fetch across the network.

  • ▸

    Context doesn't work in server components (there's no useContext). Put providers in a client component that takes children and render it as deep as possible, so static parts of the tree above it can stay static.

  • ▸

    cache from react deduplicates a function per server request (the same getUser(id) called by three components runs once). It is meant for server components: in the browser build of React cache(fn) still returns a function, but every call runs fn again (nothing is memoized).

Remember this

  1. 1

    A React Server Component (RSC) is a component that runs only on the server, either at build time or for each request. Because it never runs in the browser it can do things a browser can't: read a database or the file system, use secret API keys, and import big libraries (a Markdown parser, a date library) without sending them to the user. It can also be an async function and simply await its data: async function Post() { const post = await db.post.find(1); return <h1>{post.title}</h1>; }. Its code is never in the JavaScript bundle; only its rendered output is.

  2. 2

    A Client Component is the kind of component you've written in every earlier phase: it runs in the browser (and, in frameworks, is also pre-rendered to HTML on the server, Topic 20.1). Only client components can use state and effects (useState, useEffect, useReducer...), event handlers (onClick), browser APIs (window, localStorage), context, and custom hooks that use these. Server components can't, because there is no user clicking on the server and the component doesn't stay alive after it renders.

  3. 3

    The **"use client" directive, written as the very first line of a file, marks a boundary**: that file, and every module it imports, belongs to the client bundle. You don't put it on every client component; one directive at the entry point of an interactive part is enough. Files without it, in an RSC framework like Next.js App Router, are server components by default. "use client" is about modules, not components: a server component can import and render a client component, but a client component can't import a server component (anything it imports becomes client code).

  4. 4

    Server components send their output as the RSC payload: a compact serialized description of the rendered tree, containing plain elements, the props for each client component, and references (a module id) to client components' code, which the browser loads and runs. So everything a server component passes to a client component as props must be serializable by React: strings, numbers, booleans, null/undefined, BigInt, plain objects and arrays, Date, Map, Set, typed arrays, Promises (read them with use() on the client), JSX elements, and Server Functions (Topic 20.3). Functions (like an onClick handler), class instances and symbols not created with Symbol.for cannot cross.

  5. 5

    You can still put server-rendered UI inside a client component by passing it as **children or another JSX prop**. In <Collapsible><Comments /></Collapsible>, the parent server component renders <Comments /> on the server and passes the result into the client Collapsible, which decides where to show it. This composition pattern keeps big data-heavy parts on the server even when they sit inside interactive wrappers like tabs, modals and accordions.

  6. 6

    Why bother? Bundle savings: a blog post page that formats Markdown and highlights code on the server ships zero bytes of those libraries. Data closer to the source: no client-side request waterfall (Topic 14.1), no public API needed just for your own pages. Security: secrets and queries stay on the server (but see Topic 20.3 and Phase 21 for what still leaks if you're careless). The costs: you need a framework that supports RSC (Next.js App Router, React Router's RSC support, and others), every interaction that needs new server data goes over the network, and you must think about which side each module lives on.

Explain it without notes

01

What is a React Server Component and what can it do that a client component can't?

02

What does "use client" do exactly? Where do you put it?

03

Which props can a server component pass to a client component, and why are functions not allowed?

04

How can a client component show content rendered by a server component?

05

What are the benefits and costs of Server Components?

Practice

01

A product page shows a description (Markdown converted with a 50 KB library), a price and an "Add to cart" button with a quantity picker. Decide which parts are server and which are client components, and write the client one.

02

Rewrite this to obey the boundary rules: a server component renders <SortableTable rows={rows} onSort={(col) => rows.sort(...)} />.

Trade-offs

  • ↔

    Moving a component to the server shrinks the bundle but makes every change to its data a server round trip; very interactive widgets (drag and drop, editors) belong on the client.

  • ↔

    Placing "use client" high in the tree is easy but drags everything below it into the bundle; placing it low (leaf islands) keeps bundles small but needs more thought about props.

Done when you can

  • I can say what runs on the server and what runs in the browser in an RSC app.

  • I can place "use client" at the right boundary.

  • I know which props are serializable and why functions are not.

  • I can pass server-rendered UI into a client component through children.

  • I can use server-only to protect server modules.

  • I can explain the bundle and data benefits and the costs of Server Components.