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.
| Code | Gzipped size | Needed in the browser? |
|---|---|---|
| React and React DOM | about 45 KB | Yes, for the interactive parts |
| Markdown parser and sanitizer | about 30 KB | No: it turns text into markup once |
| Syntax highlighter with a few languages | about 25 KB | No: same reason |
| Date and unit formatting | about 15 KB | No |
| Page components (layout, body, tables) | about 20 KB | Only the button and the rating |
| Save button and rating | about 4 KB | Yes |
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.
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.
"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.
// 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
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
- The browser asks for
/recipe/42. A normalGETrequest for a document. - The server runs the server components.
RecipePageawaits the database (say 30 ms), renders the markdown, and returns JSX. When React reachesRatingStars, it does not run it. It writes a reference instead: "a client component from moduleRatingStars.js, with these props". - 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 aSuspenseboundary (a part of the tree with a fallback that can be filled in later) follow. - 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.
- The browser paints the HTML. The user sees the full recipe. Nothing is interactive yet, as with any SSR page.
- The browser loads only the client chunks. It downloads React and the code for
RatingStars, but no code forRecipePage, the markdown helper, or the database client: those were never in the client bundle. - 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.
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.
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...]}]
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.
Navigations skip the HTML
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
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.
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.
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.
- 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 boundary | Cannot cross the boundary |
|---|---|
Strings, numbers, booleans, null, undefined, bigint | Plain functions, such as onSelect={() => ...} |
Plain objects and arrays, Date, Map, Set, typed arrays | Class instances, such as a database library's model object with methods |
| JSX elements, including other server components | Symbols 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.
// 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.
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?
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.
// 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
userIdargument 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.
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 canawaitit. 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();
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.
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).
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.logprints 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 breaks | How you notice | Fix |
|---|---|---|
A "use client" high in the tree pulls half the app into the bundle | Bundle analyzer shows server-only libraries in client chunks; load time and response to taps get worse | Push the boundary to leaves; pass server content as children |
| A database row with private fields passed as a prop | The field is visible in the page source or the payload in the network panel | Map to a view object; use server-only; consider React's experimental taint APIs |
| A request waterfall in nested async components | Slow time to first byte; server traces show queries one after another | Promise.all, start fetches early, Suspense around slow parts |
| Stale data from a framework cache | Users see old prices after an update | Revalidate by tag or path in the action that changed the data; check the version's caching defaults |
| A server action without an authorization check | Security review, or worse, an incident | Authenticate, authorize, and validate inside every action |
| Hydration mismatch in a client component | Console warning; content flickers after load | Do 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.
| Question | Islands | React Server Components |
|---|---|---|
| Default for a component | Static HTML, no JS | Server component, no JS |
| How a part becomes interactive | A directive on the usage, such as client:visible | "use client" on the module that starts the client part |
| Shared state between interactive parts | Hard: separate roots, share through a store or events | Natural: one tree, context and state flow through client parts |
| Navigation | Usually a full document load | Fetch the new payload, keep client state |
| Frameworks and UI libraries | Any: React, Vue, Svelte, Solid in one page | React only |
| Best fit | Content sites with a little interactivity | Apps 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
appdirectory. - 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
- 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."
- Say what ships: "the description renderer, reviews, and recommendations ship no code; the client bundle is React plus three small widgets."
- Show the data flow: each server component fetches what it needs, with
Promise.allor early-started promises to avoid waterfalls, andSuspensearound slow parts like recommendations so the page streams. - 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.
- Cover mutations: add-to-cart is a server action that authenticates, validates, and revalidates the cart.
- 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 inSuspense. 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?
A server component runs only on the server, once per request or at build time. It can be async, read databases and secrets, and import heavy libraries, and none of its code is sent to the browser. It cannot use state, effects, event handlers, or browser APIs.
A client component is a normal React component: its code ships, it hydrates, and it can use state and handlers. Yes, it also runs on the server during the first load, to produce HTML, which is why it must not read window during render.
"use client" marks the boundary where the client part begins; files without a directive become whichever kind their importer makes them.
Question 2
What does the server send to the browser in an RSC app?
On the first load, HTML for a fast paint plus the RSC payload: a serialized description of the rendered tree. Server parts appear as finished elements; client components appear as references to their module plus their props. The payload streams, so parts waiting on data inside Suspense arrive later.
On later navigations, only the payload for the new route, which React merges into the current tree so client state survives. The exact wire format is internal to React and can change.
Question 3
How can a client component contain a server component?
It cannot import one, because the import would pull it into the client bundle. But it can receive server-rendered output as a prop, usually children. The server renders the subtree first and the client component places it.
This is how you build tabs, modals, and context providers around server content: the client wrapper owns the state, the server children own the data and ship no code.
Question 4
Are server actions safe because they run on the server?
No. Running on the server protects secrets, but each action is still a public HTTP endpoint that anyone can call with any arguments. Hiding the button does not hide the endpoint.
Inside every action: authenticate from the session, authorize the specific operation on the specific record, validate every argument with a schema, and return only what the client needs. Frameworks add origin checks and hard-to-guess IDs, but those do not replace authorization.
Question 5
How do you avoid slow pages when every component fetches its own data?
Watch for waterfalls: sequential awaits and parent-then-child fetching. Start independent requests together with Promise.all, start requests early and pass promises down, and wrap slow, non-essential parts in Suspense so the page streams.
Deduplicate repeated calls in a request with React's cache(), and put shared, slow-changing data in a framework cache with tag or path invalidation. Keep per-user data out of shared caches.
Question 6
When would you pick islands or a client-rendered app over React Server Components?
Islands suit content sites with a little interactivity, especially when you want any UI library and plain page loads; they ship even less runtime. A client-rendered app suits a highly interactive tool behind login, where almost everything is a client component and server round trips on navigation would hurt.
RSC suits data-heavy React apps with app-like navigation and shared client state, where you want to keep most components off the client without leaving React.
References
- Server Components The React reference: what server components are, async components, and how they combine with client components.
- 'use client' directive The boundary, the module graph, and the list of serializable props.
- 'use server' directive Server functions, their arguments and return values, and security notes.
- cache Per-request memoization for data functions in server components.
- Next.js: Server and Client Components How the most widely used RSC framework applies the model, including composition patterns.
- Next.js: Data Security Server action security: POST only, Origin checks, encrypted action IDs, and encrypted closure variables.
- How to Think About Security in Next.js Data access layers, tainting, and why server actions need authorization.
Read next
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- Streaming server renderingThe server sends a page's HTML in pieces as each part becomes ready, instead of waiting for its slowest data: the layout paints right away, slow sections stream in later, and each piece hydrates on its own.
- Islands and partial hydrationShip a page as static HTML and send JavaScript only for the few parts that need it, each waking up on its own trigger, so content pages stay fast on slow phones instead of paying to hydrate the whole app.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
- Choosing a rendering strategyHow to pick the rendering strategy for each page by asking who sees it, how often it changes, how interactive it is, and whether strangers must find it, then mixing strategies route by route in one app instead of choosing one for everything.
- Client-side renderingThe server sends an almost empty HTML page plus a JavaScript bundle, and the browser builds the whole page itself: easy to host and great behind a login, but slow to first content and weak for search.