Home / Articles / React Server Components: The Architecture Behind Zero-Bundle Rendering

This article is published in English.

React Server Components: The Architecture Behind Zero-Bundle Rendering

Understand the reasoning behind React Server Components, from bundle size and waterfall problems to the server-client boundary and RSC payloads.

3487 words

If you've spent time trying to wrap your head around React Server Components and came away more confused than when you started, that's not a sign you're missing something obvious. It's a sign the explanations you found were part of the problem. For a couple of years, the official framing described Server Components as "components that render on the server," a phrase that sounds suspiciously like what getServerSideProps or classic server-rendering already did in Next.js. Then came the claim that these components "ship zero JavaScript to the client," which reads like a performance trick rather than a new way of structuring an application. Then the App Router landed, and abruptly every file in a Next.js project defaulted to being a Server Component unless you dropped a special string at the top of the file.

The confusion wasn't a failure on your part. It happened because a genuinely new paradigm was being described with vocabulary borrowed from an older one. This guide aims to close that gap. By the time you finish reading, you should understand not just the mechanics of Server Components, but the reasoning that makes them a different way of thinking about React applications altogether.

The Problem That RSC Actually Solves

Before diving into how Server Components function, it helps to understand what pushed React toward inventing them in the first place. The issue was never that server-side rendering ran too slowly. The real issue is that React's component model was built around the assumption that everything happens in the browser, even in cases where that assumption added unnecessary cost.

The Bundle Size Trap

In the classic React model, every component you author turns into JavaScript that gets shipped to the browser, no exceptions. It makes no difference whether that component just renders some static markdown, does a simple date calculation, or hits a database. As long as it lives somewhere in your component tree, its code lives in your bundle too.

That creates an unforgiving trade-off. Adding more components inflates your JavaScript payload. A heavier payload pushes out your Time to Interactive. In practice, you end up delivering code to browsers that never had any reason to execute it.

The Waterfall Problem

Prior to Server Components, any component that needed data had to fetch it after it reached the client. The typical pattern was to render a placeholder shell, kick off a useEffect, wait for a response, and only then render real content. If that content included a nested component with its own data needs, the same cycle repeated: another effect, another wait, another delay stacked on top of the last one. This chain reaction is the data-fetching waterfall, and it explains why plenty of React applications feel slow even when their backend APIs respond quickly.

One workaround was moving data fetching up to the route level using tools like getServerSideProps or route loaders, but that came at a cost: it broke the self-contained nature of components. Suddenly the page had to know about data that belonged conceptually to its children, and components stopped being independent units.

The Hydration Tax

Traditional server-side rendering works by generating HTML on the server, sending it down, and then re-rendering the whole application in JavaScript on the client so it becomes interactive. That means every single component, including the ones that will never need any client-side behavior, still has to go through hydration. In effect, you pay an interactivity cost even for content that's completely static.

React Server Components address all three of these issues at the level of architecture, not as an incremental performance patch.

The Mental Model: Server Components Are Not "SSR 2.0"

Here is the central idea this whole guide rests on: Server Components aren't a way of rendering pages. They're a distinct category of component.

Classic React recognized only one kind of component. It executed in the browser. It could hold state, run effects, and respond to events. Its entire lifecycle, from creation to teardown, happened client-side.

React Server Components add a second category. A Server Component executes on the server. It's free to touch the filesystem, query a database directly, or pull in a library that only works inside Node.js. What it cannot do is use useState, useEffect, or attach event handlers, because it simply never runs in the browser. There's no hydration step for it. Its code isn't even transmitted to the client as JavaScript.

A useful way to picture this: the React component tree is now composite. Certain branches are generated on the server. Others are generated in the browser. The server-side branches render exactly once, during the request, and what they produce gets streamed to the client in the form of serialized React elements. The client-side branches render in the browser as usual and keep all the interactive capabilities you're used to.

This is a different mechanism from SSR. Server-side rendering renders the entire application on the server into HTML and then rehydrates the whole thing in the browser afterward. RSC, in contrast, renders only specific components on the server and never transmits their underlying code to the browser at all.

What Actually Gets Sent to the Client

A Server Component doesn't output HTML when it renders. Instead, it produces a serialized description of its output known as the RSC Payload — a stream of JSON-like instructions that tells React on the client side how to assemble the tree it needs to display.

Here's the sequence of events behind the scenes when a page containing Server Components gets requested:

  1. React renders the Server Components on the server.
  2. For each Server Component, it emits the actual rendered output — the elements produced, not the source code that produced them.
  3. For each Client Component, it emits a reference instead: a marker indicating where that component should be mounted, along with the props it needs.
  4. The server streams this entire payload down to the browser.
  5. The browser's React runtime parses the payload and builds out the resulting tree.
  6. Client Components then download their code and hydrate. Server Components, having already been rendered, require no hydration step at all.

The crucial takeaway is that Server Component code never makes it into the JavaScript bundle shipped to browsers. Imagine a MarkdownRenderer component built on a 200KB markdown-parsing library. If that component is a Server Component, the entire 200KB library stays put on the server. The browser only ever sees the resulting structure the parser produced, never the parser itself.

That's the substance behind the zero-bundle-size claim, and it isn't a minor optimization technique. It represents a genuine change in what building a React application actually entails.

The Server-Client Boundary

Inside the App Router, every file counts as a Server Component unless told otherwise. This isn't just a stylistic default — it's a structural assumption baked into the framework, reflecting the idea that most of your interface doesn't need to run in the browser at all.

A Client Component, by contrast, is code meant to execute on the user's machine. You mark a file this way by placing the "use client" directive at its top. This isn't merely advisory text — it functions as a hard boundary. It instructs React's compiler that everything defined in that file, along with anything it pulls in via imports, must be packaged and delivered to the browser.

The rule that keeps this whole system coherent is this: Server Components are free to import and render Client Components, but the reverse is never allowed — a Client Component cannot import a Server Component.

At first glance that feels backwards. Shouldn't server-side code be usable from anywhere, including client files? Not really, once you trace how data actually moves:

  • A Server Component executes first, with direct access to your database and backend resources. It fetches whatever data it needs and builds a layout. Somewhere in that layout it might render something like <UserProfile />, which needs interactivity — so that piece is written as a Client Component. The parent Server Component hands the fetched user data down as props.
  • The Client Component just consumes those props and renders the interactive parts. It has no way to reach back and import a Server Component, because by the time it runs in the browser, the server-side execution is long finished. There's no server process left to invoke.

That's why an arrangement like this can't work:

// ❌ Impossible: Client Component importing Server Component
'use client';
import { ServerDataFetcher } from './ServerDataFetcher'; // This breaks!
export function ClientWidget() {
  return (
    <div>
      <ServerDataFetcher /> {/* Cannot render a server component here */}
    </div>
  );
}

But structuring things the other way around is perfectly valid:

// ✅ Correct: Server Component importing Client Component
// This is a Server Component (no 'use client')
import { ClientWidget } from './ClientWidget';
async function ServerDataFetcher() {
  const data = await db.query('SELECT * FROM posts');

  return (
    <div>
      <h1>Latest Posts</h1>
      {data.map(post => (
        <ClientWidget key={post.id} post={post} />
      ))}
    </div>
  );
}

The pattern is straightforward: the Server Component handles fetching and structural rendering, then forwards data to interactive components sitting at the leaves of the tree. Those leaf components take care of clicks, form submissions, and animations. The resulting architecture is essentially a tree rendered on the server, with pockets of interactivity scattered at its edges.

Async Components: The Data Fetching Revolution

Classic React never allowed component functions to be asynchronous. Writing await directly inside a component body wasn't an option — you were stuck wiring up useEffect and juggling loading state by hand.

Server Components break that limitation: they can be async. It looks like a minor syntactic addition, but it reshapes the underlying architecture significantly.

// ✅ Server Component: Direct data access, no useEffect
async function BlogPostList() {
  // This runs on the server. No fetch call in the browser.
  const posts = await db.post.findMany({
    orderBy: { createdAt: 'desc' },
    take: 10
  });

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
        </li>
      ))}
    </ul>
  );
}

Notice everything that's absent here. There's no useState tracking a loading flag, no useEffect triggering a fetch, no manually built skeleton screen. The component simply maps database records straight into React elements. Fetching now happens per-component rather than per-route, and it happens entirely server-side, never in the browser.

Because of this, the waterfall problem disappears — the server can resolve every async component concurrently before ever sending a response to the client. Database calls execute inside the same data center as your backend, so latency is measured in microseconds rather than the milliseconds typical of round trips over a mobile connection.

The “use client” Directive: When and Why

Adding "use client" flags a file as belonging to the browser runtime. You need it whenever a component touches something inherently client-side:

  • Local state via useState or useReducer
  • Lifecycle hooks such as useEffect or useLayoutEffect
  • Event handling, like onClick or onSubmit
  • Direct browser APIs — localStorage, window, document
  • Hooks tied to browser context, including certain uses of useRouter or things like useMediaQuery

The common misstep is placing this directive too far up the component tree. Developers often convert an entire page into a Client Component just because one embedded button needs interactivity.

// ❌ Bad: Making the whole page client-side for one interactive element
'use client';
import { useState } from 'react';
import { HeroSection } from './HeroSection'; // Static, could be server
import { LikeButton } from './LikeButton';   // Interactive, needs client
export default function Page() {
  return (
    <div>
      <HeroSection />
      <LikeButton />
    </div>
  );
}

In that snippet, HeroSection is purely static markup that could easily remain server-rendered. But since "use client" was declared at the parent level, every import beneath it — including that static section — gets bundled and shipped to the browser anyway.

The Fix: Push "use client" Down the Tree

The remedy is to keep the page itself as a Server Component and treat the interactive piece as a leaf node that gets imported into it.

// ✅ Good: Page is server, only LikeButton is client
// page.tsx (Server Component by default)
import { HeroSection } from './HeroSection';
import { LikeButton } from './LikeButton';
export default function Page() {
  return (
    <div>
      <HeroSection />
      <LikeButton postId="123" />
    </div>
  );
}
// LikeButton.tsx
'use client';
import { useState } from 'react';
export function LikeButton({ postId }) {
  const [liked, setLiked] = useState(false);

  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? '❤️' : '🤍'}
    </button>
  );
}

With this structure, neither HeroSection nor Page is ever shipped to the browser as JavaScript. Only LikeButton, along with its useState call, makes the trip. This is the practical mechanism behind the zero-bundle-size goal: leave as much of the component tree as possible on the server, and reserve the client boundary for the specific nodes that truly need interactivity.

Interleaving: The Pattern That Makes RSC Powerful

The technique that gives RSC its real strength is interleaving — nesting Server Components inside Client Components, passing server-derived data as props, and letting those Client Components expose children slots that can hold further Server Components.

// Layout.tsx (Server Component)
import { Sidebar } from './Sidebar';
import { AnalyticsProvider } from './AnalyticsProvider';
export default async function DashboardLayout({ children }) {
  // Fetch user data on the server
  const user = await getCurrentUser();
  const permissions = await getUserPermissions(user.id);

  return (
    <div className="dashboard">
      <Sidebar user={user} permissions={permissions} />

      {/* AnalyticsProvider is a Client Component */}
      <AnalyticsProvider userId={user.id}>
        {/* children here can be a Server Component page */}
        <main>{children}</main>
      </AnalyticsProvider>
    </div>
  );
}
// AnalyticsProvider.tsx
'use client';
import { createContext, useContext } from 'react';
const AnalyticsContext = createContext(null);
export function AnalyticsProvider({ userId, children }) {
  // Client-side analytics initialization
  useEffect(() => {
    analytics.identify(userId);
  }, [userId]);

  return (
    <AnalyticsContext.Provider value={{ userId }}>
      {children}
    </AnalyticsContext.Provider>
  );
}

Here, DashboardLayout runs on the server and queries the database directly. It forwards that data into Sidebar, which might itself be a Server or Client Component depending on its needs. It also wraps the rest of the page in AnalyticsProvider, a Client Component required in the browser to bootstrap a third-party analytics library.

The important detail is the children prop. Whatever gets rendered inside AnalyticsProvider doesn't have to become client code just because it's nested there. React streams the already-rendered output of the Server Component through the Client Component as children — the Client Component simply acts as a wrapper or boundary, not a converter that forces everything inside it to run client-side.

This pattern is exactly how hybrid UIs get built: server-rendered content nested inside client-only wrappers exactly where interactivity is genuinely required.

The RSC Payload: A Peek Under the Hood

Server Component rendering doesn't produce HTML directly. Instead, React emits the RSC Payload, a binary stream that conceptually resembles this:

1:I["node_modules/react/jsx-runtime.js", "jsx"]
2:I["./components/ClientWidget.js", "default"]
0:["quot;, "div", null, {"children": [
  ["quot;, "h1", null, {"children": "Latest Posts"}],
  ["quot;, "@2", null, {"postId": "123", "title": "Hello World"}]
]}]

Each line in that stream is an instruction. The $ symbol marks a React element. I denotes an import of a Client Component module. A reference like @2 points back to the second import — in this case, ClientWidget. Notice that the server has already fully rendered the h1 tag, but for ClientWidget it has only forwarded the props and pointed to where its code lives, without rendering it itself.

Once the browser receives this stream, it resolves those import references, fetches the necessary Client Component bundles, and assembles the final component tree. Because the payload streams in incrementally, the browser doesn't need to wait for the whole thing before it starts rendering. This incremental delivery is precisely what lets RSC support streaming server rendering while sidestepping the usual hydration bottleneck.

Caching and Revalidation

Next.js 15 and later give Server Components access to the entire range of caching strategies:

  1. Static rendering (the default). Unless a component reads dynamic data or performs an uncached fetch, it gets rendered statically at build time.
// Cached indefinitely at build time
async function ProductList() {
  const products = await fetch('https://api.example.com/products');
  // ...
}
  1. Dynamic rendering. Calling cookies(), headers(), or reading searchParams, or explicitly setting export const dynamic = 'force-dynamic', forces the component to render on each incoming request instead.
  2. Revalidation on a timer.
// Revalidate every 60 seconds
async function ProductList() {
  const products = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 }
  });
  // ...
}
  1. Revalidation triggered on demand.
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
export async function POST() {
  revalidatePath('/products');
  return Response.json({ revalidated: true });
}

The key takeaway is that cache behavior now attaches to individual components rather than to entire routes. Two components sitting on the same page can follow completely different caching rules. That kind of granularity has no equivalent in classic SSR, where a single cache header governs the whole page at once.

Persistent Misunderstandings

"Server Components exist mainly for SEO." Not quite — better search indexing is a byproduct, not the goal. The real motivation is cutting down client-side JavaScript and allowing data fetching to happen at the component level.

"Any component that uses a hook needs use client." Only true when the hook actually depends on browser APIs. React 19's use hook can unwrap promises and read context directly inside Server Components, so plenty of hooks work fine without ever touching the client.

"Server Components make API routes obsolete." They don't — the two work together. Server Components take over the job of fetching data for the initial render, but you still need API routes to handle mutations, incoming webhooks from third parties, and any fetching that happens client-side after hydration.

"Server Components have no state." They lack browser-style state — there's no useState available — but they can freely reach into server-side state sources such as databases, caches, the filesystem, and environment variables.

"Context is off-limits in Server Components." You genuinely cannot use React Context inside a Server Component, since Context exists specifically to avoid prop drilling in client-rendered trees. What you can do instead is pass data down as plain props, and because server rendering resolves the component tree synchronously from the top down, Next.js also offers options like unstable_rootParams alongside ordinary prop drilling.

Where Things Stand Heading Into 2026

Now that React 19 has reached stability, the surrounding tooling has matured considerably:

  • Server Actions let a Client Component invoke an async function that runs on the server directly, softening the boundary between client and server when it comes to mutations.
  • The use hook gives Client Components a way to unwrap promises and context without reaching for useEffect.
  • Partial Prerendering (PPR) in Next.js 15 makes it possible to serve a static shell straight from the CDN while dynamic sections stream in from the origin server.
  • The React Compiler handles memoization of Client Components automatically, cutting down on manual useMemo calls and making the transition across the server-client boundary feel smoother.

The conceptual model has stabilized at this point. Server Components aren't a feature you opt into anymore — they're the baseline assumption behind how modern React applications are built. Client Components are now the exception: the deliberate escape hatch reserved for the interactive layer.

A Shift in Architecture, Not Just Performance

React Server Components aren't merely a performance tweak. They represent a rethinking of where React code actually executes. For roughly ten years, React was fundamentally a browser library that got bent into running server-side mainly to satisfy SEO requirements. Today it's something different: a full-stack component system that operates on both ends of the network connection, each with well-defined boundaries, responsibilities, and performance profiles.

The server has stopped being merely a place to fetch data from — it's now a genuine rendering environment. And the browser is no longer the sole habitat for React components; instead, it's specifically where interactivity lives.

Once this framing clicks, a lot of the confusion around RSC disappears. The question shifts from "do I need use client here?" to "where does this particular piece of logic belong?" That's the question worth asking, and it's the mindset that scales as applications grow.