StrategiesFRAMEWORK-SPECIFICSIMPLIFIEDSPEC-EVOLVING

Server Components

Components that run only on the server and never ship. A framework-specific answer to "how much of this tree needs to be code in the browser at all".

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What changes when a component runs only on the server and its code never reaches the browser?

The user intent

Someone wants a page that reads from a database, renders a lot of content and has a few interactive controls — without downloading the machinery for all three.

The obvious build

The tree is the tree. Every component in it is client code, so every component in it ships, and the way to make the bundle smaller is to import less inside them.

Why it breaks

Most components in a content-heavy tree never do anything on the client. They read data, format it and emit markup — behaviour the browser could have received as HTML (Islands and Partial Hydration makes the same observation from the other direction).

How it breaks in a real browser
  • Most components in a content-heavy tree never do anything on the client. They read data, format it and emit markup — behaviour the browser could have received as HTML (Islands and Partial Hydration makes the same observation from the other direction).
  • Their dependencies ship too. A date library, a markdown renderer or a syntax highlighter used only to produce static output is downloaded, parsed and executed by every visitor (Tree Shaking removes unused exports, not used ones).
  • Data fetching from inside a client component means a round trip after the bundle runs, and often several of them nested one inside another (The Life of a Fetch).
  • Keeping data access in a route-level loader and passing it down works, but it forces every leaf's data requirement up through the tree, which is where prop drilling and over-fetching come from (Prop Drilling, Context and Global State).
  • Nothing in the ordinary model distinguishes "this component needs a listener" from "this component needs a database", so both end up in the same bundle (Drawing Component Boundaries).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A component is designated server-only. It runs during the request or the build, it may read directly from a database or an internal service, and its module — including everything it imports — is never sent to the browser (Server-Side Rendering still describes where that render happens).
  • What the browser receives for that component is not HTML alone but a serialized description of the rendered output: elements, their props, and holes where client components go. The client uses it to build its tree without ever holding the server component's code (Reconciliation and Keys).
  • Client components are the leaves that need behaviour, marked explicitly. They ship, they hydrate, and everything above them may not ship at all (Hydration).
  • The boundary is a serialization boundary. Props passed from a server component to a client component must survive being written into the response, so a function, a class instance, a database handle or a closure cannot cross it. This constraint is the whole design (What a Component Owes Its Caller).
  • Composition still works across the boundary in one direction: server-rendered output can be passed into a client component as children, so an interactive wrapper can surround static content without that content becoming client code (Composition and Slots).
  • The model is orthogonal to streaming and to hydration rather than a replacement for either. A server component tree is typically streamed, and the client components inside it still hydrate (Streaming Server Rendering).

What this makes the browser do

And which of it is avoidable.

  • Parse the serialized tree description alongside the HTML — extra bytes in the response in exchange for bundle bytes that were not sent (The Real Cost of JavaScript).
  • Download and execute only the client components and their dependencies.
  • Hydrate only those client components, so the walk is over the interactive leaves rather than the document (Hydration).
  • Avoidable: everything the server components and their imports would have cost — download, parse, compile and execute (Bundle Analysis).
  • New: on client-side navigation, fetching a new serialized tree from the server rather than rendering the next route locally, which is a round trip a fully client-rendered application would not have made (Client-Side Routing).

The general idea, without the syntax

Strip away the framework and the idea is small: a component tree in which some nodes are code that runs where the data is, and some nodes are code that runs where the user is. The output of the first kind is data describing rendered elements; the second kind is what actually gets downloaded.

The reason this is worth understanding independently of any implementation is that the same pressure produced islands, route-level loaders and partial hydration. All of them are answers to "the browser is holding code that has nothing to do in the browser". Server components answer it by keeping the tree intact and moving the boundary inside it, which is why composition across the boundary works and why serialization becomes the constraint.

One tree, two locations
read directlynever shipsserialized tree + propsserializable props onlyserializable props onlyDatabase / internal servicesPage (server)Article body (server)Serialization boundaryMarkdown + highlighter (server only)Like button (client)Search box (client)Downloaded by the browser
UserLLMAgentToolDataDecisionHumanGuardrail

The boundary is a serialization boundary

FRAMEWORK-SPECIFICThe directive syntax below is React's. The rule it expresses — a component that needs a listener or browser state must be client code, and everything crossing to it must be serializable — is the general constraint; other stacks express the same split through island directives, compiler analysis or route loaders, and some do not express it at all.

Everything awkward about this model follows from one fact: the props a server component passes to a client component have to be written into the response and read back in the browser. That rules out functions, class instances, database handles and anything holding a closure — not because a framework decided to be strict, but because there is no way to send them.

The practical skill is placing the boundary. Marking a container as client code because something inside it needs a handler pulls the whole container into the bundle; marking the handler's own component pulls almost nothing. The second skill is passing rendered output rather than data, which lets an interactive wrapper surround content that never becomes client code.

Where the boundary goes, and what it costs
1// ---- Too high: the whole page becomes client code -------------------
2'use client'
3import { highlight } from 'heavy-syntax-highlighter' // ~ships to everyone
4
5export default function ArticlePage({ post, comments }) {
6 const [liked, setLiked] = useState(false) // the only reason this is
7 return ( // a client component at all
8 <article>
9 <h1>{post.title}</h1>
10 <div dangerouslySetInnerHTML={{ __html: highlight(post.body) }} />
11 <button onClick={() => setLiked(!liked)}>Like</button>
12 <Comments items={comments} />
13 </article>
14 )
15}
16
17// ---- Boundary pushed down to the one interactive leaf ----------------
18// server component: no directive, never shipped, may read data directly
19import { highlight } from 'heavy-syntax-highlighter' // stays on the server
20
21export default async function ArticlePage({ id }) {
22 const post = await db.posts.find(id) // direct read, no endpoint
23 return (
24 <article>
25 <h1>{post.title}</h1>
26 <div dangerouslySetInnerHTML={{ __html: highlight(post.body) }} />
27
28 {/* serializable props only: numbers, strings, plain objects */}
29 <LikeButton postId={post.id} initialCount={post.likes} />
30
31 {/* rendered output passed as children — Comments never ships */}
32 <CollapsiblePanel label="Comments">
33 <Comments items={await db.comments.forPost(id)} />
34 </CollapsiblePanel>
35 </article>
36 )
37}
38
39// LikeButton.tsx
40'use client'
41export function LikeButton({ postId, initialCount }) { /* ~this ships */ }
42
43// What cannot cross the boundary, and why it is not arbitrary:
44// onSave={fn} a function has no wire representation
45// client={dbHandle} a live connection cannot be serialized
46// user={userRecord} it can be serialized, and then it is public

The third comment at the bottom is the security one. user={userRecord} is not rejected by anything: it serializes cleanly, the page renders, and every field of that record is now in the response body for anyone to read. Serializable and safe to send are different questions, and only the first one is enforced.

What it is, next to the things it gets confused with

The vocabulary here collides badly. "Server rendering", "server components", "static generation" and "islands" get used as if they were four points on one axis, and they are not: two of them describe where HTML comes from, and two describe how much of the tree is client code. A page can combine them in almost any arrangement.

The table is the fastest way to keep them apart. Read the last two columns together — most confusion in this module comes from assuming that a strategy which improves one of them must improve the other.

ApproachWhere the HTML comes fromWhat ships to the browserEffect on the interactivity gap
Client rendering (Client-Side Rendering)A static shell; the browser builds the pageThe whole applicationNo gap — because nothing is visible until the bundle has already run
Server rendering (Server-Side Rendering)Rendered per requestThe whole application, plus serialized stateWidest gap: content early, interactivity unchanged
Static generation (Static Site Generation)Rendered once at build timeWhatever the page happens to import — unchanged by the strategyUnchanged, and more visible, because the paint is earlier
Streaming (Streaming Server Rendering)Rendered per request, sent in piecesThe same bundle, downloaded concurrently with the server's workNarrowed per region, not per page
Islands (Islands and Partial Hydration)Any of the aboveOnly the marked interactive regionsNarrowed substantially; interactivity becomes per island
Server componentsPer request or at build time, as a serialized treeOnly the components marked as client code, and their importsNarrowed for what remains; unchanged for each client component itself

How to build it

Most important first.

  • Default to server, opt into client. The question for each component is "does this need a listener, browser state or a browser API", and if the answer is no, it does not need to be in the browser (Drawing Component Boundaries).
  • Push the client boundary down as far as it will go. Making a whole page a client component because one button inside it needs a handler ships the page; making the button a client component ships the button.
  • Pass rendered output across the boundary rather than data, where you can. A client component that receives already-rendered children keeps that content out of the bundle entirely (Composition and Slots).
  • Project props at the boundary, exactly as with any server render. Crossing the boundary means becoming public, and passing a whole record because it was in scope is the same leak in a new place (Server-Side Rendering).
  • Keep server-only modules unimportable from client components, enforced by tooling. The failure is not a type error at the boundary; it is a secret in a bundle (The Module Graph).
  • Treat this as one lever among several. It reduces what ships; it does nothing about the interactivity gap for the components that do ship, and nothing at all about a page that is interactive everywhere (Choosing a Rendering Strategy).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Content produced by server components is in the document at first paint, so it is in the accessibility tree before any script runs — the same benefit server rendering provides, extended to more of the page (The Accessibility Tree).
  • Shipping less code narrows the interactivity gap for everything that does ship, which shortens the window in which a control announces itself as operable and is not (Hydration).
  • The boundary must not split an accessibility relationship. A label rendered by a server component and its input rendered by a client component still need a stable, matching association, and generated ids are exactly the thing that does not survive two renderers (Hydration Mismatch).
  • Composite widgets — tabs, menus, comboboxes — must keep their keyboard behaviour on one side of the boundary. A pattern whose roving focus is split across a server and a client component is a pattern with no owner (Accessible Component Patterns).
  • A client-side navigation that fetches a new tree from the server changes the page without a document load, so it announces nothing by default. That obligation does not go away because the render happened on the server (Focus Management).

What can go wrong

Failure modes
  • A server-only import reaching a client component through a shared module, pulling a database client or a secret into the bundle. Tooling catches most of this and shared utility modules are where it slips through (Storage Security and Durability).
  • The client boundary drawn too high, so a page-level component is marked as client code and everything beneath it ships regardless of what it does.
  • A prop that cannot be serialized — a function, a Date subclass, a class instance — producing a build or runtime error that is genuinely confusing the first time, because the code looks like ordinary composition.
  • Waterfalls moved rather than removed: nested server components each awaiting their own data in sequence, which is the same serial fetch problem on a different machine (Reading a Network Waterfall).
  • Navigation that requires a server round trip for a route a client-rendered application would have rendered instantly, making the application feel slower after the first load (Client-Side Routing).
  • The mitigation failing: pushing the boundary down until the tree is a scatter of tiny client components whose combined dependency graphs are larger than one coherent bundle would have been (Code Splitting).
What can arrive out of order
  • Nested server components each awaiting their own data, producing a serial chain on the server — the same waterfall the client used to have, in a place where it is harder to see (Reading a Network Waterfall).
  • A client-side navigation that fetches a new tree while a previous fetch is still in flight, so responses can arrive out of order (Out-of-Order Responses).
  • A client component hydrating before the streamed segment containing its props has arrived, which the framework must sequence and which a custom boundary can get wrong (Streaming Server Rendering).
  • Server data changing between the initial render and a subsequent tree fetch, so two parts of the same screen describe different moments (Stale-While-Revalidate).
Security
  • This is a real security improvement and a real security surface at the same time. Code that stays on the server cannot be read by a user, which removes a class of exposure — and the boundary is now a place where server data becomes public bytes, which adds one (The Browser Is a Runtime).
  • Every prop crossing into a client component is serialized into the response and is public. Review the boundary the way you would review an API response, because that is what it is (How API Shape Drives UI Complexity).
  • A server component may read from the database directly, which means authorization has to be enforced where the read happens rather than in a route handler that no longer exists as a chokepoint (What the Frontend Is Responsible For in Auth).
  • Server-invoked functions callable from the client are, in every framework that offers them, public endpoints. They authenticate, authorize and validate their input like any endpoint, regardless of how they are declared (Parse, Validate, Authorize, Process in Security Engineering covers the general rule).
  • Rendering untrusted content in a server component is the same injection surface as any server render; the escape hatch for raw HTML is the same sink (Sanitization and Trusted HTML, Cross-Site Scripting).
Misreads
  • "Server components are server-side rendering." Server rendering is about producing HTML for the initial response; server components are about which component code exists in the browser at all. A page can have either without the other (Server-Side Rendering).
  • "Server components remove hydration." They remove it for themselves. Every client component in the tree still hydrates, and the gap for those is the same gap (Hydration).
  • "This is just islands with different syntax." The idea is closely related and the mechanism is not: islands are independent roots with no shared tree, while server components form one tree whose client parts are holes in it, so composition across the boundary works in ways it cannot between islands (Islands and Partial Hydration).
  • "Anything can be a server component." Anything without a listener, browser state or a browser API can. Interactivity is what forces a component to the client, and no amount of restructuring changes that.
  • "It is the modern default, so it is the right choice." It is one framework family's answer to a real problem. Whether it fits depends on how much of your page is content, whether you can run a server, and how much navigation latency you can afford (Choosing a Rendering Strategy).

Measuring it, and what changes in the field

How you would see this
  • Bundle bytes per route, before and after moving a component across the boundary. This is the metric the model exists to move (Bundle Analysis).
  • Response size for the serialized tree, which grows as the server side grows. The trade is real bytes for real bytes, and only one side of it appears in a bundle report (Debugging the Network).
  • Script execution before first interaction, which should fall alongside the bundle (Long Tasks).
  • Navigation latency after the first load, which this model can make worse — the number to watch when a team reports the app feeling slower after adopting it (Interaction Responsiveness).
  • A bundle audit for anything server-only. This is a review step with a security consequence, not a performance one (Deploying a Frontend).
Slow device, slow network, large data, old tab
  • On a low-end device the benefit is largest, because the saving is parse and execute rather than bytes (The Real Cost of JavaScript).
  • On a content-heavy page most of the tree can be server components, and the reduction is substantial. On an application-heavy page almost nothing can, and the model mostly adds ceremony.
  • On a high-latency connection, the extra round trip per navigation is felt directly, and it is the cost most likely to surprise a team coming from a fully client-rendered application.
  • Under load, rendering happens per request on your infrastructure, with the same cost profile as any per-request render (Server-Side Rendering).
  • On a repeat visit, the client bundle is cached and the serialized tree is not, so the balance of what is downloaded shifts towards the server output (Browser HTTP Caching).
What this costs
  • Less JavaScript in the browser, bought with a serialization boundary that constrains how components compose and what props may be passed.
  • Direct data access from components, bought with authorization that has to be enforced at each access point rather than at a route handler.
  • A per-request server for every page, bought in exchange for a bundle — which is a good trade for content and a poor one for an application that was happy on a CDN (Static Site Generation).
  • A second mental model on top of an existing one. Every component now has a location as well as a contract, and the cost of getting that wrong is a bundle regression that no type checker reports.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • FRAMEWORK-SPECIFICThis is not a web platform feature and there is no specification for it: React defines a server component model with its own serialization format and directives, Vue, Svelte, Solid and Angular have no direct equivalent and address the same problem through islands, compilation or route-level loaders instead, and a page built on any of them does not have these boundaries at all. Everything here is the general idea and the constraints it implies, not an API reference.
  • SIMPLIFIEDThe teaching model here is "some components never ship, and props between the two kinds must be serializable". Real implementations add caching layers, revalidation semantics, server-invoked functions and framework-specific streaming behaviour, all of which change the operational picture without changing the boundary rule.
  • SPEC-EVOLVINGDirectives, bundler integration and the serialization format are actively changing and are not standardised across tools; treat any particular syntax as illustrative and re-check the framework's current documentation rather than trusting a remembered API.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Software Design: "where does this component run" is a deployment property leaking into a composition model. It buys real savings and it means a refactor can change what ships, which is a coupling worth naming rather than discovering in a bundle report.