The Great Split: Zero-Bundle-Size Architecture
Introducing React Server Components. How moving the Render Phase to the server allows us to delete vast amounts of JavaScript, isolate heavy dependencies, and rethink data fetching.
Part of the series: The Senior Engineer's Guide to React Server Components
- Part 1The Architect & The Builder: Reactβs Internal Split
- Part 2The Wire & The Wall: Serialization & The Flight Protocol
- Part 3The Cost of Waking Up: Hydration & The Uncanny Valley
- Part 4The Great Split: Zero-Bundle-Size Architecture
- Part 5The Hole in the Donut: RSC Composition Patterns
- Part 6Breaking the Waterfall: Streaming & Suspense
- Part 7The Loop: Server Actions & The RPC Revival
- Part 8The Senior Playbook: Architecture, Patterns & Caching
We have spent three articles defining the problem.
- The Architect (Render Phase) generates the blueprint.
- The Builder (Commit Phase) applies it to the DOM.
- In standard React, the Browser acts as both. It has to download the Architect's logic (JS Bundle) and execute it to hydrate the page.
This leads to the "Double Work" problem: We are sending code to the user just to calculate static HTML.
React Server Components (RSC) are the architectural solution. They answer a simple question: What if the Architect stayed in the office?
The Airlock: Two Environments
RSC splits React into two distinct environments that run on different computers.
1. The Server Environment (react-server)
This is the domain of Server Components.
- Capabilities: Access to the Filesystem, Database, and Node.js headers.
- Constraints: No
window, nodocument, no hooks (useState,useEffect). - Output: They do not output DOM nodes. They output Serialized Data (The Flight Payload).
2. The Client Environment (react-client)
This is the domain of Client Components.
- Capabilities: Full browser API access, interactivity, state.
- Constraints: Cannot access the DB directly.
- Output: They output real DOM nodes.
The Killer Feature: Zero Bundle Size
The most immediate benefit of this split is the ability to keep dependencies on the server.
Let's look at a real-world scenario: Rendering Markdown.
The Old Way (Client-Side Rendering)
To render a blog post written in Markdown, you need a library like marked or remark.
// BlogPost.js (Client-Side)
import { useState, useEffect } from 'react';
import { marked } from 'marked'; // β Adds 30kb to the bundle!
export default function BlogPost({ content }) {
const [html, setHtml] = useState('');
useEffect(() => {
setHtml(marked(content));
}, [content]);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}The Cost: Every user who visits your blog downloads the 30kb marked library, even though they just want to read the text. They are paying the "Hydration Tax" for a library they will never interact with.
The RSC Way
In the RSC world, we run this logic in the Server Environment.
// BlogPost.tsx (Server Component)
import { marked } from 'marked'; // β
Stays on Server (0kb to client)
export default async function BlogPost({ content }: { content: string }) {
// This runs entirely on the backend.
const html = marked(content);
// The browser receives: <div><h1>Title</h1><p>...</p></div>
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}The Result:
- Server: Executes
marked(content). Generates HTML. - Network: Sends the HTML string.
- Client: Displays the HTML. It never downloads
marked.
We have successfully isolated the "Architect" logic to the server.
Data Fetching: Async Components
Historically, data fetching in React was a mess of useEffect chains (Waterfalls).
Engineers like Joseph Savona (from the Relay team) argued that "Data dependencies should be co-located with the component."
In RSC, because the Architect is on the server (close to the database), we can drop useEffect entirely. Components can be async.
// Note: This is a Promise!
export default async function UserProfile({ id }: { id: string }) {
// 1. Pause execution here
// 2. Query DB directly (No API layer needed)
const user = await db.user.findUnique({ where: { id } });
return <div>{user.name}</div>;
}This is not magic. It works because the Render Phase is happening on the backend. The server "pauses" the rendering of this component, waits for the DB, resolves the HTML, and then streams the result to the client.
The "Use Client" Directive
So, how do we switch back to the "Builder" mode when we need interactivity (like a button)?
We use a new directive: 'use client'.
This string tells the bundler:
Stop! Everything below this line belongs to the Client Environment. Cut the bundle here and send the rest to the browser.
// LikeButton.tsx
'use client'; // π The Boundary Marker
import { useState } from 'react';
export default function LikeButton() {
const [likes, setLikes] = useState(0);
return <button onClick={() => setLikes((l) => l + 1)}>Like</button>;
}You can now import this LikeButton into your Server Page.
Summary
- The Split: React now runs in two environments: Server (Logic/Data) and Client (Interactivity/DOM).
- Zero Bundle Size: Libraries imported in Server Components are not sent to the browser.
- Async Components: Server Components can
awaitdata directly, removing the need foruseEffectfetching. - The Boundary:
'use client'marks the transition from Server Logic to Client Hydration.
We have moved the heavy lifting to the server. But now we have a new architectural puzzle: How do we mix them? If I import a Server Component into a Client Component, does it become a Client Component? (Spoiler: Yes, unless you use the "Hole" pattern).
In the next article, we master Composition.
Challenge: The Bundle Detective
You are optimizing a dashboard application. You notice the bundle size is huge (5MB). You find the following file:
File: app/dashboard/ChartWidget.tsx
'use client';
import { format } from 'date-fns'; // Heavy Lib 1
import { heavyMath } from 'math-lib'; // Heavy Lib 2
import { Line } from 'react-chartjs-2'; // Interactive Chart
export default function ChartWidget({ rawData }) {
// Expensive processing happening on the Client!
const processedData = rawData.map((d) => ({
x: format(new Date(d.date), 'yyyy-MM-dd'),
y: heavyMath(d.value),
}));
return <Line data={processedData} />;
}Task: Refactor this code to move the "Heavy Math" and "Date Formatting" to the Server, leaving only the Chart on the Client.
Click to Reveal Solution
Step 1: Create a Server Component Wrapper Process the data before passing it to the client component.
// app/dashboard/page.tsx (Server Component)
import { format } from 'date-fns'; // β
Stays on Server
import { heavyMath } from 'math-lib'; // β
Stays on Server
import ChartClient from './ChartClient'; // The Client Part
export default async function Dashboard() {
const rawData = await db.getData();
// Transform Data HERE (The Architect)
const processedData = rawData.map((d) => ({
x: format(new Date(d.date), 'yyyy-MM-dd'),
y: heavyMath(d.value),
}));
// Pass ready-to-use data to the Builder
return <ChartClient data={processedData} />;
}Step 2: The Client Component Now it only needs the charting library.
// app/dashboard/ChartClient.tsx
'use client';
import { Line } from 'react-chartjs-2';
export default function ChartClient({ data }) {
return <Line data={data} />;
}