A4 · Part 3 · Advanced
Server Components
Components that run once, on the server, and ship no JavaScript at all.
13On this page
- What they are
- Not server-side rendering
- The boundary
- What crosses the boundary must serialise
- Sharing data with the client tree through Context
- What actually changes
- The rules, compressed
- What actually travels over the wire
- Request deduplication and cache
- Errors and loading, per segment
- Where it goes wrong
- The server boundary is an attack surface: React2Shell
- The mental model
What they are
A Server Component runs on the server, during the render, and its code is never sent to the browser. It can await a database query directly. It cannot use state, effects, or event handlers — because there is no browser to run them in and it never runs again.
// no "use client" — this is a Server Component by default in the App Router
export default async function Page({ params }) {
const post = await db.post.findUnique({ where: { id: params.id } });
return <article>{post.body}</article>;
}
No useEffect, no loading state, no API route in between, and the
db client is not in the bundle.
Not server-side rendering
| SSR | Server Components | |
|---|---|---|
| Runs on the server | yes | yes |
| Runs again in the browser | yes — hydration | no |
| Code shipped to the client | all of it | none of it |
| Output | HTML | a serialised element tree (RSC payload) |
SSR renders your components on the server to produce HTML fast, then ships the same components down to run again. Server Components run only on the server; the client receives a description of the result, not the code that produced it.
The boundary
// app/page.tsx — server
import LikeButton from "./LikeButton";
export default async function Page() {
const post = await getPost();
return (
<article>
<h1>{post.title}</h1>
<LikeButton postId={post.id} /> // island of interactivity
</article>
);
}
// LikeButton.tsx
"use client";
export default function LikeButton({ postId }) {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(!liked)}>...</button>;
}
"use client" marks the boundary. Everything that file imports is
a client component too, transitively — so the directive is a doorway,
not a per-component label. Put it as low in the tree as possible.
A client component can render a server one — as children
<ClientTabs>
<ServerChart /> // ✓ passed as an element, already rendered
</ClientTabs>
A client component cannot import a server component — the
server code would follow it into the bundle. It can receive one as
children, because by then it is a rendered payload rather than
code. This is the single most useful pattern for keeping an interactive
shell around static content.
What crosses the boundary must serialise
<Client date={new Date()} /> // ✓ Dates, Maps, Sets are supported
<Client onSave={() => save()} /> // ✗ functions cannot cross
<Client user={classInstance} /> // ✗ class instances lose their prototype
Props are serialised into the RSC payload. Functions are the one that catches people — the exception being Server Actions, which are passed as a reference the client can call.
Sharing data with the client tree through Context
A Server Component cannot create Context — createContext
needs the client. It can render one, as long as the Context is defined in
a "use client" module.
// user-context.js
"use client";
import { createContext } from "react";
export const UserContext = createContext(null);
// layout.js — a Server Component, React 19.3 and later
import { UserContext } from "./user-context";
export default async function Layout({ children }) {
const user = await getCurrentUser();
return <UserContext value={user}>{children}</UserContext>;
}
Before React 19.3 the client module also had to export a wrapper — usually
called UserProvider — whose only job was to take a prop and
hand it straight to the Context. That file is gone now. It matters most for
Contexts that exist only so server-fetched data, such as the current user or a
feature-flag set, reaches the interactive components below.
The value still has to serialise, like any prop crossing the boundary. Pass the user object, not a class instance or a function.
What actually changes
- Data fetching moves next to the data. No API layer built solely so the browser can reach the database.
- Waterfalls collapse. Sibling Server Components fetch in parallel during one server render, instead of each discovering its needs after its parent painted.
- Bundles shrink. A markdown renderer, a syntax highlighter, a date library used only for formatting — none of them ship.
- Secrets stay put. API keys and database URLs are used in code that never reaches a browser.
The rules, compressed
| Server | Client | |
|---|---|---|
async/await in the component | yes | no |
| Database, filesystem, secrets | yes | no |
| State, effects, handlers | no | yes |
| Browser APIs | no | yes |
| Ships JavaScript | none | yes |
What actually travels over the wire
0:["$","article",null,{"children":[["$","h1",null,{"children":"Title"}],
["$L1",null,{"postId":7}]]}]
1:I["./LikeButton.js",["chunk-a.js"],"default"]
The RSC payload is a streamed, line-oriented format — not HTML and not
JSON in the usual sense. Rendered server output is inline;
$L1 is a placeholder for a client component, and the
I line tells the browser which chunk to load for it.
Two consequences worth knowing. It streams, so the client can start rendering the top of the page before the bottom has been produced. And it describes elements, not HTML — which is why a client navigation can patch a subtree without discarding the state of the components around it. A full HTML response could not do that.
Request deduplication and cache
const getUser = cache(async (id) => db.user.find(id));
// three components calling getUser(7) in one render → one query
Because Server Components fetch where they are used rather than at the top,
the same data is often requested several times in one render. React dedupes
identical fetch calls automatically within a request, and
cache() extends that to any function.
React 19.2 adds cacheSignal, which gives you an
AbortSignal tied to the cache entry's lifetime — so work can
be cancelled when the request that needed it goes away.
Errors and loading, per segment
app/dashboard/
page.tsx
loading.tsx // a Suspense fallback for this segment
error.tsx // a client error boundary for this segment
Frameworks turn file conventions into the boundaries from
Suspense and
error boundaries. Worth knowing
it is the same mechanism — error.tsx must be a client
component, because error boundaries are still classes and still need to run in
the browser.
Where it goes wrong
"use client"at the top of a layout. Everything below becomes client. The directive belongs on the leaf that needs it.- A client component importing a server utility that pulls in the database client — the build will tell you, loudly.
- Fetching the same thing in three components. React dedupes identical requests within one render pass, but only if the calls are actually identical.
- Expecting
useStateto work in a file with no directive. The error message is clear once you have seen it once.
The server boundary is an attack surface: React2Shell
On 3 December 2025 the React team disclosed CVE-2025-55182, rated CVSS 10.0 and widely called React2Shell. An unauthenticated attacker could send a crafted HTTP request to any Server Function endpoint and, when React deserialised it, run code on the server. Google's threat intelligence group saw widespread exploitation almost immediately, with cryptominers deployed from 5 December — two days after disclosure.
Follow-up disclosures on 11 December 2025 and in January 2026 added more, and one original fix turned out to be incomplete:
| CVE | Severity | What it allowed |
|---|---|---|
| CVE-2025-55182 | Critical, 10.0 | Remote code execution through a Server Function request |
| CVE-2025-55184 | High, 7.5 | Denial of service — an infinite loop that hangs the server process |
| CVE-2025-67779 | High, 7.5 | Denial of service |
| CVE-2026-23864 | High, 7.5 | Denial of service — crashes, out-of-memory, runaway CPU |
| CVE-2025-55183 | Medium, 5.3 | Source code exposure from a Server Function that stringifies its arguments |
The flaws were in react-server-dom-webpack,
react-server-dom-parcel and
react-server-dom-turbopack — the packages every RSC framework
sits on, including Next.js, React Router, Waku, Parcel and the Vite RSC plugin.
Safe versions are 19.0.4, 19.1.5 and 19.2.4 or later. Anyone who stopped
at 19.0.3, 19.1.4 or 19.2.3 still had the incomplete DoS fix.
What to take from it
- An app with no server is not affected. A pure client-side React app never deserialises a Server Function request. The risk arrived with the server boundary.
- Every Server Function is a public endpoint. The attack needed no login and no knowledge of your code. Validate and authorise inside each one, as you would a REST handler.
- Upgrade the framework, not only React. Frameworks bundle their own copy of the RSC runtime. Follow the framework's advisory, and do not rely on a hosting provider's firewall rule instead of patching.
- Keep secrets out of source. CVE-2025-55183 leaked hardcoded strings, not
process.envvalues. A key that lives only in an environment variable was not exposed by it. - Version hygiene is a skill. Knowing which version you run, reading advisories, and patching within days is part of shipping Server Components.
The mental model
Think of the page as mostly static content with islands of interactivity. The static parts are computed once on the server and cost nothing at runtime; the islands are small, and they are the only JavaScript the user downloads. That inverts the default of the last decade, where everything shipped and hydrated whether it needed to or not.
Interview answer, one sentence
"Server Components run only on the server and ship no JavaScript — unlike
SSR, which runs the same components again in the browser. They can await
data directly, which removes the API layer and the render-fetch waterfall,
and 'use client' marks a doorway rather than a single
component, so it belongs as low in the tree as possible."
Opens in the editor: write it, run it, and check it against real tests.
All 54 for this level →Finished reading?
0 of 57 chapters read in this series