본문으로 건너뛰기
KYH
  • Blog
  • About

joseph0926

I document what I learn while solving product problems with React and TypeScript.

HomeBlogAbout

© 2026 joseph0926. All rights reserved.

nextjsreact

How does RSC split server/client bundles?

Tracing Next.js internals to explain how React Server Components split server and client bundles from build-time transforms to runtime restoration.

Nov 01, 20254 min read
How does RSC split server/client bundles?

They say the bundle will be reduced, how?

We know that RSC reduces bundle size, but the actual implementation mechanism was unclear. We traced the Next.js repository code and confirmed the bundle separation process from build time to runtime.

The key question was: What does it mean for the same file to be bundled differently on the server and client?

// components/Button.tsx
'use client';

export default function Button() {
  return <button onClick={() => alert('clicked')}>Click</button>;
}

// app/page.tsx
import Button from '@/components/Button';

export default function Page() {
  return (
    <div>
      <h1>Welcome</h1>
      <Button />
    </div>
  );
}

In this simple code, how would Button.tsx be included in the server bundle and client bundle respectively? And where does the server component Page go?


1. Identity of ‘use client’

First, we traced how the 'use client' directive is processed during the build process.

Role of webpack loader

Next.js analyzes files in next-swc-loader,

// packages/next/src/build/webpack/loaders/next-swc-loader.ts
const FORCE_TRANSPILE_CONDITIONS =
  /next\/font|next\/dynamic|use server|use client|use cache/;

async function loaderTransform(
  this: LoaderContext<SWCLoaderOptions>,
  source?: string,
  inputSourceMap?: any,
) {
  // Check if 'use client' exists
  if (shouldMaybeExclude && !FORCE_TRANSPILE_CONDITIONS.test(source)) {
    return [source, inputSourceMap];
  }

  // Convert to SWC
  return transform(source as any, programmaticOptions);
}

When this loader finds the string 'use client', it asks the Rust compiler (SWC) to translate it.

Conversion of SWC

SWC converts 'use client' to a comment,

// Before conversion
'use client'
export default function Button() { ... }

// After conversion (conceptual)
/* __next_internal_client_entry_do_not_use__ default auto */
export default function Button() { ... }

This comment acts as a marker in later steps to indicate that the file is a Client Component.

Handling of next-flight-loader

Next, next-flight-loader detects this annotation and processes it differently for each bundle,

// packages/next/src/build/webpack/loaders/next-flight-loader/index.ts
export default function transformSource(
  this: LoaderContext<undefined>,
  source: string,
  sourceMap: any,
) {
  const buildInfo = getModuleBuildInfo(module);
  buildInfo.rsc = getRSCModuleInformation(source, true);

  // In case of Client Component
  if (buildInfo.rsc?.type === RSC_MODULE_TYPES.client) {
    const stringifiedResourceKey = JSON.stringify(resourceKey);

    // For server bundles: replace actual code with reference
    if (assumedSourceType === 'module') {
      let esmSource = `
import { registerClientReference } from "react-server-dom-webpack/server";

export default registerClientReference(
  function() { 
    throw new Error(\`Attempted to call the default export of \${stringifiedResourceKey} from the server\`);
  },
  ${stringifiedResourceKey},
  "default",
);
`;
      return this.callback(null, esmSource, sourceMap);
    }
  }

  // If it is not a Client Component, leave it as the original.
  return this.callback(null, source, sourceMap);
}

개발자가 작성한 코드

'use client'
export default function Button() {
return (
<button onClick={() => alert('clicked')}>
Click me
</button>
)
}

'use client' 지시어로 시작

The key here is that the same file is processed twice.

  • Server bundle: Replaced with registerClientReference call.
  • Client bundle: Original code intact

2. Structure of reference objects

What does registerClientReference return?

Looking at React’s code,

// packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js
export function registerClientReference(proxyImplementation, id, exportName) {
  return Object.defineProperties(proxyImplementation, {
    $$typeof: { value: CLIENT_REFERENCE },
    $$id: { value: id },
    $$async: { value: false },
    // ...
  });
}

The returned object is a reference object with a special $$typeof property.

{
  $$typeof: Symbol.for('react.client.reference'),
  $$id: '/absolute/path/to/Button.tsx#default',
  $$async: false
}

This object is not an actual component, but rather a marker that says "here is a client component."


3. Serialization on the server

When rendering a component on the server, what happens when it encounters this reference object?

// app/page.tsx (Server Component)
export default function Page() {
  return (
    <div>
      <h1>Welcome</h1>
      <Button /> {/* Actually a reference object */}
    </div>
  );
}

React traverses the tree and when it finds a reference object, it serializes it as an RSC Payload.

// packages/react-server/src/ReactFlightServer.js (conceptual)
function renderElement(element) {
  if (element.$$typeof === CLIENT_REFERENCE) {
    // Serialize reference information
    return serializeClientReference(element);
  }
  // Normal elements are rendered as is
}

The final generated RSC Payload,

M1: {"id":"./src/components/Button.tsx","name":"default","chunks":["client123"]}
0: ["$","div",null,{"children":[
0:   ["$","h1",null,{"children":"Welcome"}],
0:   ["$","@1"]
0: ]}]

RSC Payload 구조

M1모듈 정의
{
  "id": "./src/components/Button.tsx",
  "name": "default",
  "chunks": [
    "client-123"
  ]
}
id: 파일 경로
name: export 이름
chunks: 클라이언트에서 로드할 번들 ID
0렌더 트리
["$","div",null,{"children":[
["$","h1",null,{"children":"Welcome"}],
["$","@1"]
]}]
@1: M1 모듈을 이 위치에 렌더링
M1 또는 @1에 마우스를 올려보세요

Here,

  • M1: Client Component definition (file path, export name, chunk ID)
  • @1: Placeholder for "Render M1 here"

Step 4: Restore on the client

When the browser receives this data,

<!DOCTYPE html>
<html>
  <body>
    <div>
      <h1>Welcome</h1>
    </div>

    <script>
      self.__next_f.push([
        1,
        'M1:{"id":"./Button.tsx","chunks":["client123"]}',
      ]);
      self.__next_f.push([1, '0:["$","div",null,{"children":...}]']);
    </script>
    <script src="/_next/static/chunks/client-123.js" async></script>
  </body>
</html>

processing process,

  1. HTML parsing: <h1>Welcome</h1> is immediately displayed on the screen
  2. Save RSC Payload: Accumulated in self.__next_f array
  3. Chunk Load: Import the actual Button component from client-123.js
  4. Hydration: Connect the actual component to the @1 position.

하이드레이션 타임라인

0ms
HTML 렌더링
10ms
RSC Payload 파싱
50ms
청크 로드 시작
150ms
청크 로드 완료
200ms
하이드레이션 완료

the actual contents of the client bundle;

// client-chunk-123.js
export default function Button() {
  return React.createElement(
    'button',
    {
      onClick: () => alert('clicked'),
    },
    'Click',
  );
}

The original code is included. Unlike server bundles, this is actual executable code.


Summary: Mechanism of bundle separation

To summarize the entire process,

Build time

// 1. Written by developer
'use client'
export default function Button() { ... }

// 2. SWC conversion
/* __next_internal_client_entry_do_not_use__ */
export default function Button() { ... }

// 3. next-flight-loader branch
// Server Bundle:
export default registerClientReference(...)

// Client Bundle:
export default function Button() { ... }  // keep original

Runtime

// 4. Server rendering
<Page>
  → <Button /> (reference object found)
  → Create RSC Payload: M1: {...}, @1

// 5. Client reception
HTML: <div><h1>Welcome</h1></div>
Payload: M1, @1
Chunk: client-123.js

// 6. Hydration
@1 location + client-123.js → Actual Button rendering

번들 분리 과정

Button.tsx
'use client'

export default function Button() {
  return <button>Click</button>
}
서버 번들
registerClientReference(
  function() { throw Error() },
  "/Button.tsx",
  "default"
)
✓ 참조만 포함 (용량 절약)
클라이언트 번들
export default function Button() {
  return <button>Click</button>
}
✓ 실제 코드 포함 (실행 가능)

Reasons for bundle size reduction

The benefits of RSC are now clear.

// Server Component
import _ from 'lodash'; // 2MB library
import { db } from './db';

export default async function Page() {
  const users = await db.getUsers();
  const grouped = _.groupBy(users, 'country');

  return (
    <div>
      <UserList data={grouped} /> {/* Client Component */}
    </div>
  );
}

Included in server bundle only

  • Server Component Code (Page)
  • lodash library
  • Database access code

Included in client bundle only

  • Client Component code (UserList)
  • Only libraries used by UserList

Send to client

  • Server Component execution Result (HTML + RSC Payload)
  • Client Component Reference Information
  • Server component code is not transmitted.

클라이언트 번들 크기 비교

React
100KB
Page 컴포넌트
50KB
lodash
200KB
DB 코드
30KB
UserList 컴포넌트
40KB
총 번들 크기
420KB

The bundle is determined depending on where the same file is used. ```tsx // Use only on servers → Only in server bundles import _ from 'lodash';

// Only used on clients → Only on client bundles ('use client'); import _ from 'lodash';

// Use both → both bundles

---

Lastly, to summarize the key points:

### 1. ‘use client’ is a build time marker

It does not change runtime behavior, but is a marker that webpack uses to split bundles. The pipeline from SWC → next-flight-loader handles this.

### 2. Same file, different processing

The Client Component files are bundled twice.

- For servers: replaced by reference object
- For clients: maintain original code

### 3. Serialization is key

The core of RSC is the process of serializing a reference object on the server into an RSC Payload and restoring it to an actual component on the client.

### 4. Bundle separation is automatic

Developers only need to write `'use client'` and the build system will take care of the rest. webpack analyzes the dependency graph and creates the optimal bundle.

---

## Finish

RSC's unbundling wasn't just "magic"; it was the result of a structured build pipeline.

One line `'use client'` sets the whole process in motion: SWC conversion → webpack loader → reference object creation → RSC Payload serialization → hydration.

By understanding this mechanism, you can clearly determine “why it works this way” and “what code goes where” when using RSC.

Deeper implementation details can be found in the Next.js and React repositories.

- `packages/next/src/build/webpack/loaders/next-flight-loader/`
- `packages/react-server-dom-webpack/src/`