cf.completefrontend
My progress
FUNDAMENTALS · MODULE 7

React Server Components

React components that run only on the server and send their rendered result, not their code, to the browser: data fetching moves into components, heavy libraries stay off the client, and only components below a "use client" boundary ship JavaScript.

The problem: every component ships, even the ones that never change

Picture a recipe site built with React and classic server-side rendering. A recipe page has a title, a long body written in markdown, a list of ingredients, a nutrition table, a "Save" button, and a star rating the reader can click. Only the last two respond to the user. Everything else is text that never changes once it is on screen.

With classic SSR (server-side rendering, where the server runs your components to produce HTML), the server renders the whole page to HTML, and the browser shows it quickly. Then the browser downloads the JavaScript for every component on the page and runs it again to attach event handlers. That second run is hydration, and it needs the code of every component, including the ones that only print text.

So the markdown parser ships to the browser, and so do the date formatting library and the syntax highlighter for the code snippets in the recipe notes. Each of those exists only to turn data into markup, and the markup is already in the HTML. The browser downloads, parses, and runs them to produce output it already has.

What that costs, in numbers

Let us put rough sizes on the recipe page. These are typical gzipped sizes for popular libraries, not exact measurements of any one version, but the shape is what matters.

CodeGzipped sizeNeeded in the browser?
React and React DOMabout 45 KBYes, for the interactive parts
Markdown parser and sanitizerabout 30 KBNo: it turns text into markup once
Syntax highlighter with a few languagesabout 25 KBNo: same reason
Date and unit formattingabout 15 KBNo
Page components (layout, body, tables)about 20 KBOnly the button and the rating
Save button and ratingabout 4 KBYes

Of roughly 140 KB of JavaScript, about 50 KB does work the user can feel. A mid-range phone parses and runs very roughly 1 MB of JavaScript per second, about 1 ms per KB of unzipped code (the rule of thumb from the estimation lesson). Gzip shrinks JavaScript about 3.5 times, so the extra 90 KB is about 315 KB of code, or roughly 300 ms of main-thread work before the rating responds to a tap. On a slow phone it is much more.

A bar chart of the recipe page's client JavaScript, gzipped, drawn to scale. With classic SSR and hydration: about 140 KB, made of React 45 KB, a markdown parser 30 KB, a syntax highlighter 25 KB, date formatting 15 KB, page components 20 KB, and the save button and rating 4 KB. With server components: about 49 KB, only React and the two interactive widgets.client JavaScript for one recipe page, gzipped, to scaleclassic SSR + hydrationReactmarkdownsyntaxdatepage140 KBserver components + client leavesReact49 KBmarkdown, syntax, date, page: code that only turns data into markup
Same page, same output. With server components, the libraries that only turn data into markup stay on the server, and only React plus the two interactive widgets ship.

The second problem: data lives far from components

Classic React apps also split data fetching away from the components that need it. Either each component fetches in the browser after it mounts (a slow chain of requests from the user's device), or a page-level function such as a framework's server loader fetches everything up front and threads it down as props. The first is slow; the second couples one big loader to every leaf of the tree.

React Server Components (RSC) address both problems with one idea: some components run only on the server. They can read a database or call an internal API directly, because they never run in the browser. Their output is sent to the browser as a description of the UI, and their code is never sent at all. Components that need state or event handlers are marked as client components, and only their code ships.

Check your understanding · Exercise 1

On the recipe page with classic SSR and hydration, which pieces of code does the browser download? Select all that apply.

The mental model: two kinds of components in one tree

Keep one picture in your head: a single React tree where every component runs in exactly one of two places. Server components run on the server, once per request (or once at build time), and are done. Client components are the React components you already know: they can hold state, handle clicks, and use browser APIs. They render to HTML on the server too, for the first paint, and then hydrate and keep running in the browser.

Server components are the default. In an RSC framework such as the Next.js App Router, a component is a server component unless something makes it a client component. You never mark a file as "server". You mark the point where the client part begins.

An everyday comparison: a furniture store

Think of a furniture store that delivers. Most pieces, like a bookshelf, arrive fully assembled: the store did the work in its workshop, and you get only the result. A few pieces, like a recliner, arrive with their motor and remote, because the moving part has to keep working in your living room.

Server components are the assembled bookshelf: you get the finished output and none of the tools that built it. Client components are the recliner: they come with the machinery (their JavaScript) because they keep doing things after delivery. Classic SSR is a store that delivers the assembled bookshelf and the workshop tools, just in case.

A component tree for the recipe page. RecipePage is the root, with children Header, RecipeBody, and Reviews, all server components. At the leaves: SearchBox under Header, Markdown and Rating under RecipeBody, and LoadMore under Reviews. SearchBox, Rating, and LoadMore are client components marked use client; every other component is a server component that ships no code.RecipePageserverHeaderserverRecipeBodyserverReviewsserverSearchBoxuse clientMarkdownserverRatinguse clientLoadMoreuse clientshaded boxes: server components, run once, no code shippedbold boxes: client components, code ships and hydrates
One tree, two kinds of component. The bold boxes start with "use client": only their code ships and hydrates. Everything else runs once on the server.

"use client" marks a door, not a file type

The "use client" directive at the top of a file says: "this module, and everything it imports, belongs to the client side". It is an entry point from the server world into the client world, like a door between two rooms. Once you walk through it, everything below is in the client room.

This has a consequence people miss. A component file with no directive is not "a server component file". It is a shared module that becomes whichever kind its importer makes it.

If a server component imports Price.tsx, Price runs on the server. If a client component imports the same Price.tsx, Price is bundled for the browser. The same code can be both, in two places on the same page.

tsx
// app/recipe/[id]/page.tsx  (no directive: a server component)
import { db } from "@/lib/db"; // stays on the server
import { RatingStars } from "./RatingStars"; // a client component
import { renderMarkdown } from "./markdown"; // stays on the server

export default async function RecipePage({ params }) {
  const recipe = await db.recipe.find((await params).id);
  return (
    <article>
      <h1>{recipe.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: renderMarkdown(recipe.body) }} />
      <RatingStars recipeId={recipe.id} initial={recipe.rating} />
    </article>
  );
}

// app/recipe/[id]/RatingStars.tsx
"use client";
import { useState } from "react";
export function RatingStars({ recipeId, initial }) {
  const [stars, setStars] = useState(initial);
  // ...buttons with onClick
}

The page is async and reads the database directly. The markdown helper runs on the server and never ships (the body is sanitized at save time in this example). RatingStars is the door: its code, and React itself, go to the browser. The recipe object stays on the server; only recipeId and initial cross the door as props.

Check your understanding · Exercise 2

Price.tsx has no directive. A server component ProductPage and a client component CartDrawer (marked "use client") both import it. What happens? Select all that apply.

How a request works, step by step

Let us follow one request for the recipe page, from the click to an interactive star rating. Two different outputs come out of the server: the RSC payload, a serialized description of the rendered tree, and the HTML made from it. Keeping those two apart is the key to everything else.

The first load

  1. The browser asks for /recipe/42. A normal GET request for a document.
  2. The server runs the server components. RecipePage awaits the database (say 30 ms), renders the markdown, and returns JSX. When React reaches RatingStars, it does not run it. It writes a reference instead: "a client component from module RatingStars.js, with these props".
  3. React serializes the result into the RSC payload. Plain elements (article, h1, the rendered markdown) become data. Client components become references plus props. The payload is streamed: parts that are ready go out first, and parts waiting on data inside a Suspense boundary (a part of the tree with a fallback that can be filled in later) follow.
  4. The SSR step turns the payload into HTML. The same server runs the client components once, in a server environment, to produce their first HTML (stars with the initial rating). The HTML streams to the browser, with the payload inlined in script tags as it goes.
  5. The browser paints the HTML. The user sees the full recipe. Nothing is interactive yet, as with any SSR page.
  6. The browser loads only the client chunks. It downloads React and the code for RatingStars, but no code for RecipePage, the markdown helper, or the database client: those were never in the client bundle.
  7. React hydrates the client parts. It reads the embedded payload to rebuild the tree, attaches handlers to RatingStars, and leaves the server parts as they are. Hydration work is proportional to the client components, not to the whole page.
A sequence diagram of the first load. The browser sends GET /recipe/42 to the RSC render. The RSC render queries the database, which answers in 30 ms. The RSC render streams the payload to the SSR step. SSR sends HTML with the payload embedded to the browser. Later, a streamed chunk with the reviews follows. The browser paints, loads only the client chunks, and hydrates only the client parts.BrowserRSC renderSSRDatabaseGET /recipe/42query reciperow, 30 mspayload streamHTML + payloadreviews, laterbrowser: paint, load only client chunks,then hydrate only the client parts
Two outputs leave the server: the payload, which describes the tree, and the HTML made from it. The browser never receives the code of the server components.

What the payload looks like

The payload format is an internal detail of React and can change between versions, so never parse it yourself. Loosely, it is a stream of numbered rows. Some rows are trees of plain elements, some point at a client module to load, and some fill in a part that was still waiting on data.

text
1:I["/chunks/rating-stars.js", "RatingStars"]
0:["$","article",null,{"children":[
    ["$","h1",null,{"children":"Lemon tart"}],
    ["$","div",null,{"dangerouslySetInnerHTML":{"__html":"<p>Whisk the eggs...</p>"}}],
    ["$","$L1",null,{"recipeId":42,"initial":4}],
    ["$","$Sreact.suspense",null,{"fallback":"Loading reviews","children":"$L2"}]
]}]
2:["$","ul",null,{"children":[...reviews...]}]
A simplified sketch of an RSC payload, not the exact wire format.
A simplified RSC payload, one row per line, with notes. Row 1 names client code to load, rating-stars.js. Row 0 starts the tree with an article. Inside it: an h1 with Lemon tart, a plain element; a div whose markdown is already HTML; a hole L1 for the client component with props recipeId 42; and a Suspense boundary with hole L2, filled later. Row 2 arrives later with the reviews list and fills hole 2.1: I["rating-stars.js"]client code to load0: ["$","article", ...start of the tree ["$","h1", "Lemon tart"]a plain element ["$","div", html: "Whisk..."]markdown, as HTML ["$","$L1", {recipeId: 42}]hole: client + props ["$", suspense, "$L2"]hole, filled later2: ["$","ul", reviews...]row 2 fills the hole
Server output arrives as finished elements. Client components arrive as a reference plus props, and Suspense holes are filled by rows that stream in later.

Three things to notice. The markdown arrives as finished markup, so the parser never ships. $L1 is a hole: "put the client component from row 1 here, with these props". $L2 is a hole that row 2 fills later, when the reviews query finishes: that is streaming, the same idea you saw in streaming SSR, applied to the component tree.

When the user clicks a link to another recipe, the router does not ask for a new HTML document. It asks the server for the RSC payload of the new route only, and React merges the new tree into the page. Client components that stay on screen, such as a search box in the layout, keep their state and focus. This is why an RSC app feels like a single-page app even though most of its components run on the server.

The payload for a navigation is usually small, because it carries rendered output rather than code. A route whose server components import 200 KB of libraries might send 8 KB of payload. The cost moves elsewhere: every navigation to uncached server content now needs a server round trip, which is why prefetching and caching matter so much in RSC frameworks.

Check your understanding · Exercise 3

A user on /recipe/42 clicks a link to /recipe/43 in an RSC app. Which statements are true? Select all that apply.

Server components in detail: what they can and cannot do

A server component is a function that runs once, on the server, and returns JSX. That single fact explains its whole list of powers and limits. It can do anything the server can do. It cannot do anything that needs to happen later, in the browser.

Async components and data where it is used

Server components can be async functions. You await data right inside the component that needs it, with no useEffect, no loading flag, and no client-side request. The data source can be a database, an internal service that the browser cannot reach, the file system, or a plain fetch to an API.

tsx
import "server-only";
import { db } from "@/lib/db";

export async function Reviews({ recipeId }: { recipeId: number }) {
  const reviews = await db.review.findMany({
    where: { recipeId },
    orderBy: { createdAt: "desc" },
    take: 20,
  });
  return (
    <ul>
      {reviews.map((r) => (
        <li key={r.id}>{r.author}: {r.text}</li>
      ))}
    </ul>
  );
}

The import "server-only" line is a guard from a small npm package. If any client module ever imports this file, the build fails. Use it in every module that holds secrets, database clients, or internal URLs, so a refactor can never pull them into the browser bundle by accident.

The limits, and why each one exists

Every limit follows from "runs once, on the server". There is no browser, so no window or localStorage. There is no later, so no state and no effects: useState, useReducer, useEffect, and useLayoutEffect are not allowed.

Nothing of the component runs on the user's device, so there is nothing to call an onClick handler. And there is no long-lived component instance in the browser to hold or read context, so useContext is not allowed either: context is a client feature.

A matrix of what each kind of component can do. Server components: await data in render yes, read secrets and the database yes, import heavy libraries at 0 KB yes, useState and useEffect no, event handlers no, window and localStorage no, context no, render HTML on first load yes. Client components: the opposite for the first three and the next four, and yes for rendering HTML on first load.serverclientawait data in renderread secrets and the DBimport heavy libs at 0 KBuseState and useEffectonClick and other handlerswindow and localStorageuse contextrender HTML on first loadyesnoyesnoyesnonoyesnoyesnoyesnoyesyesyesruns once, on theserver: no state,no handlers, nobrowser APIs
Every row follows from one fact: a server component runs once, on the server. Note the last row: client components also render to HTML on the server, so they must not read window during render.

Client components are not "browser-only" components. On the first load they still run on the server, once, to produce HTML. So a client component must not touch window during render either: read it in an effect or an event handler, exactly as with any SSR app.

What "zero JavaScript" really means

A server component adds no component code to the bundle. It is not free on the wire, though. Its output travels twice on the first load: once as HTML and once inside the payload that React needs to rebuild the tree.

For a long article the payload can be as big as the HTML. The win is in code and in hydration work, not in total bytes.

  • Code you save: the component itself and every library it imports, such as a markdown parser, a date library, or a syntax highlighter.
  • Work you save: hydration skips server components. On a page that is 80% server components, hydration does roughly 20% of the work it would do with classic SSR.
  • What you still pay: React's client runtime and the framework's router (often 50 to 100 KB gzipped together, depending on versions), the payload bytes, and the code of every client component.

Check your understanding · Exercise 4

Which of these can you do inside a server component? Select all that apply.

The boundary: placing "use client" well

Where you put "use client" decides how much JavaScript ships and what each component is allowed to do. Three rules govern the boundary: everything imported below it is client code, only serializable values can cross it as props, and server components can still sit inside client components if you pass them in as children.

Rule 1: the boundary pulls in everything it imports

A client module's import graph is bundled for the browser. If ProductCard.tsx is marked "use client" because it has an "Add to cart" button, then the formatPrice helper, the ReviewSummary component, and the markdown parser it imports all ship too, even though none of them needs the browser.

The fix is to push the boundary down to the leaves. Keep ProductCard a server component and extract only the button into AddToCartButton.tsx with the directive. The card's markup, the price formatting, and the review summary stay on the server. A 40 KB client card becomes a 2 KB client button.

Press a component to put "use client" at the top of its file. Everything it imports becomes client code too.

A product page tree. ProductPage: server, 0 KB. Gallery: breaks. Description: server, 0 KB. PriceTag: server, 0 KB. AddToCart: breaks. Reviews: server, 0 KB. Stars: server, 0 KB. Helpful: breaks. Client JavaScript: 0 KB.ProductPageserver, 0 KBGallerybreaksDescriptionserver, 0 KBPriceTagserver, 0 KBAddToCartbreaksReviewsserver, 0 KBStarsserver, 0 KBHelpfulbreaksclient JavaScript0 KB (nothing interactive works)
Bold boxes ship their code. Boxes that break are server-only work pulled into the client, or interactive parts left on the server.
  • Gallery keeps the current photo in useState, but it runs as a server component: hooks and event handlers are not allowed there.
  • AddToCart has an onClick handler, but it runs as a server component: hooks and event handlers are not allowed there.
  • Helpful counts votes in useState, but it runs as a server component: hooks and event handlers are not allowed there.

Rule 2: only serializable props cross the boundary

Props from a server component to a client component travel inside the payload, so they must survive serialization. React supports more than JSON, but not everything.

Can cross the boundaryCannot cross the boundary
Strings, numbers, booleans, null, undefined, bigintPlain functions, such as onSelect={() => ...}
Plain objects and arrays, Date, Map, Set, typed arraysClass instances, such as a database library's model object with methods
JSX elements, including other server componentsSymbols that are not globally registered
Promises (the client reads them with use) and server actions (functions marked "use server")Live resources: database connections, sockets, file handles

The second column is where bugs come from. Passing a database row object straight to a client component often works, because it looks like a plain object, but it sends every field, including ones you never meant to show: an email address, an internal flag, a password hash. Map rows to small view objects before they cross: { id, name, price }, not user.

Rule 3: pass server components through as children

A client component cannot import a server component: the import would pull it into the client graph. But a client component can receive server-rendered output as a prop, usually children. The server renders that subtree first and hands the client component a finished slot to place wherever it likes.

tsx
// Tabs.tsx
"use client";
import { useState } from "react";
export function Tabs({ labels, children }) {
  const [active, setActive] = useState(0);
  return (
    <div>
      {labels.map((l, i) => (
        <button key={l} aria-pressed={i === active} onClick={() => setActive(i)}>{l}</button>
      ))}
      {children[active]}
    </div>
  );
}

// page.tsx (server component)
<Tabs labels={["Details", "Reviews"]}>
  {[<ProductDetails id={id} key="d" />, <Reviews id={id} key="r" />]}
</Tabs>

Tabs holds the state and the buttons. ProductDetails and Reviews are server components that query the database and ship no code. The same pattern covers modals, accordions, layouts with a collapsible sidebar, and context providers: make a small client ThemeProvider that takes children, and wrap your server tree in it.

Two side-by-side pictures. Left, a client Tabs component imports Reviews: Reviews becomes client code, ships, and can no longer query the database. Right, the server page renders Tabs and passes Reviews as children: Tabs is client code, while Reviews stays a server component with 0 KB shipped.Tabs imports ReviewsTabsuse clientimportReviewsnow client codeships, cannot await the DBpage passes childrenProductPageserverTabs (client) with a slotReviewsserver, 0 KBReviews stays on the server
Same components, different wiring. An import pulls Reviews across the boundary; passing it as children keeps it on the server and hands Tabs a finished slot.

Decision A product card has a big static body and one "Add to cart" button. Where does the boundary go?

Mark the whole card as a client component

Put "use client" at the top of ProductCard.tsx.

Pros

  • Simplest to write; the card can use state anywhere.

Cons

  • The card and all its imports ship and hydrate.
  • Data must be fetched above and passed down as serializable props.

Extract a client leaf

Keep the card a server component; move only the button into AddToCartButton.tsx with the directive.

Pros

  • Smallest bundle and hydration cost.
  • The card can fetch its own data.

Cons

  • One more file; state that spans the whole card needs another approach.

Client wrapper with server children

Make a client CardShell that owns hover or expand state and takes the body as children.

Pros

  • Interactivity around server content, such as expand and collapse, without shipping the content's code.

Cons

  • The shell cannot read or change the children's data; it only places them.

What we pick: Default to extracting client leaves. Reach for a client wrapper with server children when the interactive part surrounds the content (tabs, modals, accordions). Mark a whole subtree client only when most of it truly reacts to the user, like a form builder or a drawing canvas.

Check your understanding · Exercise 5

A server component renders <Chart data={rows} onPointClick={(p) => log(p)} />, where Chart is a client component. What happens? Select all that apply.

Server actions: mutations, and why they are public endpoints

Server components read data. To change data, React has server functions, usually called server actions when they handle a form or a mutation. You write an async function with the "use server" directive, and the client can call it as if it were local. The framework turns each one into an HTTP endpoint and wires the call up for you.

tsx
// actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";

const Rating = z.object({ recipeId: z.coerce.number().int(), stars: z.coerce.number().int().min(1).max(5) });

export async function rateRecipe(formData: FormData) {
  const session = await getSession();
  if (!session) throw new Error("Not signed in");
  const input = Rating.parse(Object.fromEntries(formData));
  await db.rating.upsert({ userId: session.userId, ...input });
  revalidatePath(`/recipe/${input.recipeId}`);
}

// in a server component:
<form action={rateRecipe}>
  <input type="hidden" name="recipeId" value={recipe.id} />
  <button name="stars" value="5">Five stars</button>
</form>

Passing the action to a form element gives you progressive enhancement: the form posts and works before any JavaScript loads, and once React hydrates it submits without a full page reload. After the mutation, the action tells the framework which cached pages to refresh, and the server sends back fresh payload for the current route in the same response.

Every action is a public endpoint

This is the part that matters most for security. The nice local-function syntax hides a plain fact: each server action has an ID, and anyone who finds that ID can POST to it with any arguments they like, using curl, with no UI at all. The button being hidden for non-admins protects nothing.

  • Authenticate inside the action. Read the session from the cookie on every call. Never trust a userId argument from the client.
  • Authorize inside the action. Check that this user may change this record: "is this user the author of comment 981?", not just "is someone signed in?".
  • Validate every argument. Types in TypeScript do not exist at runtime. Parse with a schema library and reject anything unexpected, including hidden form fields, which the user can edit.
  • Return only what the caller needs. An action's return value is serialized to the client, just like props.
A flowchart for a server action. A POST arrives with the action id and arguments. Is the caller signed in? If no, reject with 401. Do the arguments pass the schema? If no, reject with 400. May this user edit this row? If no, reject with 403. If every check passes, write the change and revalidate the affected pages.POST action id + argssigned in?yesno401, sign inargs pass schema?yesno400, bad inputmay edit this row?yesno403, forbiddenwrite, revalidate
The action runs these checks itself, on every call. A hidden button or a hard-to-guess action id skips none of them for an attacker with curl.

Frameworks add some protection. Next.js, for example, accepts only POST for actions, compares the Origin header with the host to block cross-site calls, gives actions encrypted IDs that are hard to guess and change between builds, and keeps the IDs of unused actions out of the client bundle. Treat these as a seat belt, not a lock: the checks inside the action are yours to write.

Check your understanding · Exercise 6

An admin page shows a "Delete user" button only to admins. The button calls a server action deleteUser(id). The action deletes the row. What is wrong? Select all that apply.

Data fetching, waterfalls, and caching

Fetching inside components is convenient, and it brings back an old problem in a new place: the waterfall, where one request cannot start until another finishes. On the server the round trips are shorter than from a phone (a query to a nearby database often takes a few milliseconds to a few tens of milliseconds), but a chain of five still adds up, and every millisecond delays the first byte of the page.

How waterfalls form in a server tree

There are two common shapes. The first is sequential awaits inside one component: const user = await getUser(); const orders = await getOrders(); when the second call does not need the first. The second is parent then child: a layout awaits the user, then renders a page that awaits its own data, which renders a sidebar that awaits more. React cannot start rendering a child until its parent returns JSX, so each level waits for the one above.

  • Start independent requests together. const [user, orders] = await Promise.all([getUser(), getOrders()]). Total time is the slowest request, not the sum.
  • Start early, await late. Create the promise in the parent and pass it down as a prop. A client component can read it with use(promise); a server child can await it. The request is already in flight while the tree renders.
  • Wrap slow parts in Suspense. The page streams with a fallback while the slow part loads, so one slow query no longer holds back the rest. This is the streaming SSR model applied to server components.
  • Preload. Call a cached fetch function early (for example at the top of the page) without awaiting it, so a deeper component that calls it later gets a result that is already on its way.

await getUser(); await getFeed(); await getAds();

A timeline of three server data calls: getUser 40 ms, getFeed 120 ms, getAds 60 ms. Sequential awaits: getUser() runs from 0 to 40 ms, getFeed() runs from 40 to 160 ms, getAds() runs from 160 to 220 ms. First content can stream at 220 ms; everything is done at 220 ms.getUser()40 msgetFeed()120 msgetAds()60 msfirst content 220 ms0 ms100 ms200 ms
Bars are drawn to scale, 2 units per millisecond. The line marks when the server can start streaming real content.

Each call waits for the one before: 40 + 120 + 60 = 220 ms before the page can send content.

React cache(): one request, one result

Fetching where data is used means several components may ask for the same thing. The header, the page, and the sidebar might all call getUser(). React's cache() function wraps a data function so that, within one server request, calls with the same arguments run once and share the result.

ts
import { cache } from "react";
import "server-only";

export const getUser = cache(async (id: string) => {
  return db.user.findUnique({ where: { id } });
});

// Header, Page, and Sidebar all call getUser("u_17"):
// one database query per request, three components served.

Two details trip people up. cache() memoizes for the duration of one request only: the next request starts empty, so it is deduplication, not a shared cache. And it compares arguments by identity, so pass primitives like an id string, not a fresh object each time, or every call will miss.

Framework caches: powerful, and version-specific

Frameworks add caches above React's. The Next.js App Router has had several layers: a data cache for fetch results across requests, a full route cache that stores the rendered payload and HTML of static routes, and a client-side router cache that keeps visited payloads in the browser for a while. These are what make RSC pages fast without a database hit on every request.

They are also the part that changes most between versions. Next.js 13 and 14 cached fetch results by default, which surprised many teams with stale data. Next.js 15 stopped caching fetch calls and GET route handlers by default. Next.js 16 adds an opt-in model called Cache Components, built around a "use cache" directive, and the details may keep moving.

Read the docs for the exact version you run. In an interview, describe the layers and their invalidation rather than flags: time-based, or on demand by path or by tag (a label you attach to cached entries so one call can clear them all).

A layered stack of caches in an RSC app. Top, the browser: a router cache of visited payloads, kept briefly per tab. Next, one server request: React cache() deduplicates calls with the same arguments, for that request only. Next, across requests on the server: a data cache of fetch results and a route cache of rendered payload and HTML, invalidated by time, path, or tag. Bottom, the data sources: the database and internal APIs.browserrouter cachevisited payloadsper tab, short-livedone requestReact cache()same args, one callgone after the requestacross requestsdata cachefetch resultsroute cachepayload + HTMLsourcesdatabaseinternal APIs
Each layer has its own scope and lifetime. cache() lives for one request; framework caches outlive requests and need invalidation; exact names and defaults depend on the framework version.

Check your understanding · Exercise 7

A server page does const user = await getUser() (40 ms), then const feed = await getFeed() (120 ms), then const ads = await getAds() (60 ms). None depends on another. What helps? Select all that apply.

Trade-offs and what breaks in production

RSC is not free. It trades client cost for server cost and for a more complex model. A senior answer names those costs plainly and says when they are worth paying.

The costs

  • Framework lock-in. RSC needs a bundler that splits server and client module graphs and a server that streams the payload. In practice that means a framework: the Next.js App Router is the most mature; React Router, Waku, and Parcel have added support more recently, some of it still experimental. Moving off is a rewrite of the data layer.
  • Mental model. Every file now has an implicit question: "which side am I on?". New team members hit errors such as "you are importing a component that needs useState" or a function prop that cannot be serialized, and the fix is not always obvious.
  • Debugging in two places. A server component's console.log prints in the server terminal, not the browser console. A bug can live in the server render, in the payload, in the SSR pass of a client component, or in hydration. Error boundaries and logs have to cover both sides.
  • Server cost and latency. Work that the browser used to do now runs on your servers for every uncached request. Navigations to dynamic routes need a server round trip, so a slow server or a far region makes the app feel slow even with tiny bundles.
  • Less useful for very interactive apps. A spreadsheet, a design tool, or a chat client is mostly client components anyway. RSC still helps their shell and their settings pages, but the main canvas gains little.

Failure modes, and how you notice them

What breaksHow you noticeFix
A "use client" high in the tree pulls half the app into the bundleBundle analyzer shows server-only libraries in client chunks; load time and response to taps get worsePush the boundary to leaves; pass server content as children
A database row with private fields passed as a propThe field is visible in the page source or the payload in the network panelMap to a view object; use server-only; consider React's experimental taint APIs
A request waterfall in nested async componentsSlow time to first byte; server traces show queries one after anotherPromise.all, start fetches early, Suspense around slow parts
Stale data from a framework cacheUsers see old prices after an updateRevalidate by tag or path in the action that changed the data; check the version's caching defaults
A server action without an authorization checkSecurity review, or worse, an incidentAuthenticate, authorize, and validate inside every action
Hydration mismatch in a client componentConsole warning; content flickers after loadDo not read window, the time, or random values during render; use an effect

Most of these show up in tools you already have. The network panel shows the payload requests on navigation (in Next.js they carry an RSC request header), and a bundle analyzer shows exactly which modules landed on the client. Check both whenever a page feels heavier than it should.

Check your understanding · Exercise 8

For which product would you expect the smallest gain from React Server Components? Pick one.

RSC compared with islands, and in real products

If you read the islands lesson, RSC will look familiar. Both ship JavaScript only for the parts that need it, and both render the rest to HTML on the server. The difference is in what the page is after it loads.

With islands (Astro is the best-known example), the page is static HTML with a few independent interactive roots. Each island hydrates on its own, and islands do not share a React tree. Navigation is usually a normal page load, or a view transition that swaps documents.

With RSC, the page is one React tree where server parts are fixed output and client parts are live. Navigation fetches a new payload and React merges it, so shared client state such as an open player or a search box survives.

Two browser windows showing the same podcast page. Left, built with islands: static HTML with two separate interactive roots, a search island and a player island, which do not share state, and a full page load on navigation. Right, built with server components: one React tree where the header, episode text, and episode list are server output, and the search and player are client components in the same tree, so the player keeps playing across navigations.islandsstaticroot 1static episode textroot 2: playerserver componentsserverclientserver episode textclient player, one treeplayer stops on page loadplayer keeps playing
Both ship code only for the search and the player. Islands are separate roots on a static page; with server components they are leaves of one tree that survives navigation.
QuestionIslandsReact Server Components
Default for a componentStatic HTML, no JSServer component, no JS
How a part becomes interactiveA directive on the usage, such as client:visible"use client" on the module that starts the client part
Shared state between interactive partsHard: separate roots, share through a store or eventsNatural: one tree, context and state flow through client parts
NavigationUsually a full document loadFetch the new payload, keep client state
Frameworks and UI librariesAny: React, Vue, Svelte, Solid in one pageReact only
Best fitContent sites with a little interactivityApps with lots of server data and app-like navigation

Resumability, the approach of Qwik, goes further than both: it avoids hydration by serializing enough state into the HTML that event handlers can load on first interaction. RSC still hydrates client components; it just has fewer of them.

In real products

  • Next.js App Router made RSC mainstream in 2023 and is where most production RSC code runs today. Server components are the default in its app directory.
  • Shopify Hydrogen experimented with server components in its first version, then moved to Remix for its 2023 release and later to React Router 7 (2025), a reminder that early RSC tooling was hard to adopt.
  • Waku was built around RSC from the start, and React Router and Parcel added RSC support in 2025 (React Router's as an experimental preview, Parcel's as a beta), so the model is no longer tied to one framework, though maturity varies.
  • Content-heavy sites, such as docs and marketing pages built on the App Router, are where teams report the clearest wins: large client bundles from markdown, syntax highlighting, and CMS SDKs shrink to the code of a few widgets.

Check your understanding · Exercise 9

A team builds a podcast site. A mini player at the bottom must keep playing while users browse episodes, and episode pages are mostly text. Which statements are fair? Select all that apply.

Using React Server Components in a system design interview

RSC comes up when the question is about a content-rich product with some interactivity: a product page, a news feed, a docs site, a dashboard with heavy server data. It also comes up when the interviewer asks "how would you cut the JavaScript on this page?" Bring it up as one tool among rendering strategies, not as the answer to everything.

What to draw and say

  1. Draw the component tree and mark the client leaves: "the gallery carousel, the size picker, and add-to-cart are client components; everything else is a server component."
  2. Say what ships: "the description renderer, reviews, and recommendations ship no code; the client bundle is React plus three small widgets."
  3. Show the data flow: each server component fetches what it needs, with Promise.all or early-started promises to avoid waterfalls, and Suspense around slow parts like recommendations so the page streams.
  4. Name the caches: shared data such as product details in a cross-request cache with tag invalidation; per-user data such as the cart fetched per request.
  5. Cover mutations: add-to-cart is a server action that authenticates, validates, and revalidates the cart.
  6. Close with the trade-off: server load and round trips on navigation versus smaller bundles and less hydration, and why that suits this product.

How deep to go by level

A junior candidate should know that server components run only on the server, ship no code, and cannot use state or handlers. A mid-level candidate should place the boundary well, explain serializable props and the children pattern, and avoid waterfalls. A senior candidate should talk about caching layers and invalidation, server actions as public endpoints, the cost on servers and navigations, and when RSC is the wrong tool compared with islands or a client-rendered app.

Check your understanding · Exercise 10

An interviewer asks how you would render a product page for an online shop with RSC. Which points belong in a strong answer? Select all that apply.

Summary

  • Server components run only on the server and send their output, not their code: heavy rendering libraries and data clients never reach the browser.
  • "use client" marks where the client part of the tree begins; everything that module imports ships, so push the boundary down to interactive leaves.
  • Only serializable props cross the boundary. Send small view objects, never raw database rows or functions; pass server content into client components as children.
  • The RSC payload is a streamed, serialized tree. First loads get HTML plus payload; navigations get payload only and keep client state.
  • Fetching in components invites waterfalls: use Promise.all, start requests early, and wrap slow parts in Suspense.
  • cache() deduplicates within one request; framework caches persist across requests and differ by version, so know how yours invalidates.
  • Server actions are public endpoints: authenticate, authorize, and validate inside every one.
  • The price is server load, a round trip per dynamic navigation, framework coupling, and a two-sided mental model; very interactive apps gain the least.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

What is the difference between a server component and a client component, and does a client component run on the server?

Question 2

What does the server send to the browser in an RSC app?

Question 3

How can a client component contain a server component?

Question 4

Are server actions safe because they run on the server?

Question 5

How do you avoid slow pages when every component fetches its own data?

Question 6

When would you pick islands or a client-rendered app over React Server Components?

References

Read next