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.

By Satish Kumar ・ December 13, 2025

We have spent three articles defining the problem.

  1. The Architect (Render Phase) generates the blueprint.
  2. The Builder (Commit Phase) applies it to the DOM.
  3. 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, no document, 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:

  1. Server: Executes marked(content). Generates HTML.
  2. Network: Sends the HTML string.
  3. 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:

// 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

  1. The Split: React now runs in two environments: Server (Logic/Data) and Client (Interactivity/DOM).
  2. Zero Bundle Size: Libraries imported in Server Components are not sent to the browser.
  3. Async Components: Server Components can await data directly, removing the need for useEffect fetching.
  4. 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} />;
}

Subscribe to my free Newsletter

Join my weekly newsletter to get the latest updates on design engineering, new projects, and articles I've written. No spam, unsubscribe at any time.