DEV Community

Cover image for Next.js Module Graphs: The Hidden Architecture Behind Server and Client Components
Zwel๐Ÿ‘ป
Zwel๐Ÿ‘ป

Posted on

Next.js Module Graphs: The Hidden Architecture Behind Server and Client Components

Most Next.js tutorials tell you: "Server Components run on the server, Client Components run on the client." That's true, but it hides a massive architectural detail that will confuse you the moment you try to share state between them.

In this article, we'll build a small experiment, observe something that seems impossible, and then peel back the layers of the Next.js rendering pipeline to understand exactly what's happening.


The Experiment

Let's create a simple closure-based state module and share it between a Server Component and a Client Component.

The Shared State Module

// store/state.ts

let funcInitializeCount = 0;

export const stateCreator = () => {
  funcInitializeCount++;

  console.log("stateCreator initialized", funcInitializeCount);

  let count = 0;

  const setCount = (value: number) => {
    console.log("setCount", value);
    count = value;
  };

  const increase = () => {
    count++;
  };

  const decrease = () => {
    count--;
  };

  return { count: () => count, setCount, increase, decrease };
};

export const myState = stateCreator();
Enter fullscreen mode Exit fullscreen mode

When this module loads, stateCreator() runs immediately and creates a closure with count = 0. The funcInitializeCount variable tracks how many times the factory runs โ€” we'll use this to detect re-initialization.

Page A โ€” A Server Component That Mutates State

// app/a/page.tsx

import { myState } from "@/store/state";
import { ClientComp } from "../_components/client-comp";

export default function PageA() {
  const { count, setCount } = myState;

  setCount(1);

  console.log("Page A", count());

  return (
    <div className="p-4 flex flex-col gap-4 items-start">
      <h1>Page A, {count()}</h1>

      <ClientComp name="Page A" />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Page A imports myState, calls setCount(1) to mutate count to 1, then renders both the count and a <ClientComp />.

Page B โ€” A Server Component That Reads State

// app/b/page.tsx

import { myState } from "@/store/state";
import { ClientComp } from "../_components/client-comp";

export default function PageB() {
  const { count } = myState;

  console.log("Page B", count());

  return (
    <div className="p-4 flex flex-col gap-4 items-start">
      <h1>Page B, {count()}</h1>

      <ClientComp name="Page B" />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Page B just reads count() โ€” no mutation. We'll use this to verify shared state behavior within the same world.

The Client Component

// app/_components/client-comp.tsx

"use client";

import { Button } from "@/components/ui/button";
import { myState } from "@/store/state";

export function ClientComp({ name }: { name: string }) {
  const { count, increase, decrease } = myState;

  console.log("Client component from", name, "=>", count());

  return (
    <div className="space-y-2">
      <h1>
        Client component, {count()} from {name}
      </h1>

      <Button onClick={increase}>Increase</Button>
      <Button onClick={decrease}>Decrease</Button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

ClientComp also imports myState and reads count(). It has the "use client" directive.


The Puzzle

Visit /a. You see:

Page A, 1
Client component, 0 from Page A
Enter fullscreen mode Exit fullscreen mode

Wait. Both PageA and ClientComp import the same myState from the same store/state.ts file. PageA calls setCount(1), so count is now 1. Then ClientComp reads count() โ€” so why does it show 0?

Three possible explanations come to mind:

  1. Next.js re-initializes stateCreator before rendering ClientComp โ€” But then funcInitializeCount would be 2, and we'd see a second "stateCreator initialized 2" log. We don't.

  2. Next.js uses the existing (mutated) state for ClientComp's HTML โ€” Then the server HTML would say 1, but the browser's fresh state would say 0. That's a hydration mismatch. We don't get one.

  3. Something else entirely is going on. โœ…

The answer is option 3, and it requires understanding the Next.js module graph architecture.


The Two Module Graphs

When you build a Next.js App Router application, the bundler doesn't create one bundle. It creates separate module graphs for different environments:

1. The RSC Module Graph (Server Bundle)

This graph contains your Server Components and every module they import โ€” up to (but not crossing) the "use client" boundary.

RSC Module Graph
โ”œโ”€โ”€ app/a/page.tsx        (Server Component)
โ”œโ”€โ”€ app/b/page.tsx        (Server Component)
โ””โ”€โ”€ store/state.ts        โ† loaded here, stateCreator() runs, count = 0
Enter fullscreen mode Exit fullscreen mode

When the RSC renderer hits <ClientComp />, it does not follow the import into client-comp.tsx. Instead, it records a component reference with the serialized props ({ name: "Page A" }) and moves on.

2. The Client Module Graph (Client Bundle)

This graph starts at every "use client" boundary and includes everything those files import.

Client Module Graph
โ”œโ”€โ”€ app/_components/client-comp.tsx   ("use client" entry point)
โ””โ”€โ”€ store/state.ts                    โ† loaded AGAIN, separately
Enter fullscreen mode Exit fullscreen mode

store/state.ts appears in both graphs. But these are completely independent module instances โ€” separate funcInitializeCount variables, separate closures, separate count values.

Why Two Graphs?

This isn't a quirk โ€” it's a fundamental design choice. Server Components can use Node.js APIs, access the filesystem, query databases directly, and use secrets. That code must never end up in the browser bundle. The "use client" directive is a fence that tells the bundler: "Everything above this line stays on the server. Everything below ships to the client."

The cost of this safety is that module-level state cannot cross the boundary.


The Four Rendering Phases

Now let's trace exactly what happens when you visit /a, step by step through all four phases.

Phase 1: RSC Rendering (Server)

Where: Server process
Bundle: RSC (Server) module graph
Purpose: Generate a serialized component tree (RSC Payload)

1. Next.js invokes PageA()
2. PageA imports myState from the RSC module graph's state.ts
   โ†’ stateCreator() already ran when the module loaded
   โ†’ count = 0 in this closure
3. setCount(1) โ†’ count is now 1 in the RSC closure
4. count() returns 1 โ†’ renders "Page A, 1"
5. Hits <ClientComp name="Page A" />
   โ†’ ClientComp has "use client"
   โ†’ RSC does NOT execute ClientComp
   โ†’ Instead, emits a reference: { type: ClientComp, props: { name: "Page A" } }
6. Outputs RSC Payload (a serialized tree, not HTML)
Enter fullscreen mode Exit fullscreen mode

Key insight: The RSC renderer never runs ClientComp. It can't โ€” that component may use useState, useEffect, browser APIs, etc. Instead, it creates a placeholder that says "render this client component here with these props."

Server logs from this phase:

setCount 1
Page A 1
Enter fullscreen mode Exit fullscreen mode

Phase 2: SSR โ€” Server-Side Rendering of Client Components

Where: Server process (same machine, different context)
Bundle: Client module graph
Purpose: Generate the initial HTML so the user sees content before JavaScript loads

1. Next.js takes the RSC Payload from Phase 1
2. For each client component reference, it executes the component
   using the CLIENT module graph
3. ClientComp imports myState from the CLIENT graph's state.ts
   โ†’ This is a DIFFERENT module instance
   โ†’ stateCreator() ran when THIS module loaded
   โ†’ count = 0 in THIS closure (setCount(1) never happened here)
4. count() returns 0 โ†’ renders "Client component, 0 from Page A"
5. The complete HTML is generated and sent to the browser
Enter fullscreen mode Exit fullscreen mode

Key insight: The client bundle's state.ts has no knowledge of the setCount(1) call. That mutation happened in the RSC module graph's copy. This module's count has been 0 since initialization.

Server logs from this phase:

stateCreator initialized 1    (client bundle's initialization)
Client component from Page A => 0
Enter fullscreen mode Exit fullscreen mode

Phase 3: Browser โ€” JavaScript Loading

Where: User's browser
Bundle: Client module graph (downloaded JS chunks)
Purpose: Load the JavaScript needed for interactivity

1. Browser downloads the client JS bundle
2. state.ts module executes in the browser
   โ†’ stateCreator() runs โ†’ count = 0
   โ†’ funcInitializeCount goes from 0 to 1 (fresh variable)
3. All client modules are now loaded and ready
Enter fullscreen mode Exit fullscreen mode

Browser console logs from this phase:

stateCreator initialized 1
Enter fullscreen mode Exit fullscreen mode

Phase 4: Hydration

Where: User's browser
Bundle: Same client JS from Phase 3
Purpose: Attach event listeners and make the page interactive

1. React walks the server-rendered HTML
2. It executes ClientComp with { name: "Page A" }
3. count() returns 0 (from the browser's closure)
4. React compares: server HTML says "0", client render says "0" โ†’ โœ… MATCH
5. Hydration succeeds โ€” no errors
6. Event listeners attached โ€” buttons now work
Enter fullscreen mode Exit fullscreen mode

Browser console logs from this phase:

Client component from Page A => 0
Enter fullscreen mode Exit fullscreen mode

Why funcInitializeCount Is Always 1

This is the most telling proof that we have separate module worlds. If it were a single shared module, we'd see:

stateCreator initialized 1    (first load)
stateCreator initialized 2    (second load?? โ€” this NEVER happens)
Enter fullscreen mode Exit fullscreen mode

Instead, we see:

Phase Environment funcInitializeCount
RSC Rendering Server (RSC bundle) 1
SSR Server (Client bundle) 1
Browser Load Browser (Client bundle) 1

Three separate 1s. Three separate module instances. Three separate counters all starting from 0.


Shared State Within the Same World

While state cannot cross the "use client" boundary, it is shared within the same module graph.

Server Components Share State

If you visit /a first (which calls setCount(1)), then visit /b:

Page B, 1
Enter fullscreen mode Exit fullscreen mode

Page B sees count = 1 even though it never called setCount. Why? Because both PageA and PageB are Server Components in the same RSC module graph. They share the same state.ts module instance, so mutations from one are visible to the other.

โš ๏ธ Warning: This is actually dangerous. Module-level mutable state in Server Components is shared across requests in production (since the server process persists). This can leak data between users. Never use module-level mutable state for user-specific data in Server Components.

Client Components Also Share State (in the Browser)

In the browser, if you click "Increase" on one page's ClientComp, then navigate (client-side) to another page, the other ClientComp will see the updated count. They share the same browser-side module instance.


The Rendering Pipeline โ€” Visual Summary

                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                    โ”‚     Your Source Code          โ”‚
                    โ”‚     store/state.ts            โ”‚
                    โ”‚     app/a/page.tsx            โ”‚
                    โ”‚     app/_components/          โ”‚
                    โ”‚       client-comp.tsx         โ”‚
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                   โ”‚
                         Next.js Build
                                   โ”‚
                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                    โ”‚                              โ”‚
              โ”Œโ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”                 โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”
              โ”‚  RSC       โ”‚                โ”‚  Client     โ”‚
              โ”‚  Bundle    โ”‚                โ”‚  Bundle     โ”‚
              โ”‚            โ”‚                โ”‚             โ”‚
              โ”‚ state.ts โ‘  โ”‚                โ”‚ state.ts โ‘ก  โ”‚
              โ”‚ page-a.tsx โ”‚                โ”‚ client-     โ”‚
              โ”‚ page-b.tsx โ”‚                โ”‚  comp.tsx   โ”‚
              โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜                โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                    โ”‚                              โ”‚
          Phase 1: RSC                    Phase 2: SSR
          Render Server                   Render Client
          Components                      Components
          (count=0โ†’1)                     (count=0)
                    โ”‚                              โ”‚
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                               โ”‚
                         HTML + RSC Payload
                         sent to browser
                               โ”‚
                               โ–ผ
                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                    โ”‚   Browser            โ”‚
                    โ”‚                      โ”‚
                    โ”‚   Phase 3: Load JS   โ”‚
                    โ”‚   state.ts โ‘ข         โ”‚
                    โ”‚   (count=0)          โ”‚
                    โ”‚                      โ”‚
                    โ”‚   Phase 4: Hydrate   โ”‚
                    โ”‚   count=0 matches    โ”‚
                    โ”‚   server HTML โ†’ โœ…    โ”‚
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

  โ‘ โ‘กโ‘ข = Three separate instances of the same module
Enter fullscreen mode Exit fullscreen mode

The Golden Rule

The "use client" boundary is not a rendering hint โ€” it's a module graph split.

You cannot share runtime state across it. The only way to pass data from a Server Component to a Client Component is through serializable props.

The Wrong Way

// Server Component
export default function PageA() {
  const { setCount } = myState;
  setCount(1);
  // โŒ ClientComp will never see count = 1
  return <ClientComp name="Page A" />;
}
Enter fullscreen mode Exit fullscreen mode

The Right Way

// Server Component
export default function PageA() {
  const count = await getCountFromDB();  // or any server-side data
  // โœ… Pass it as a prop โ€” the only bridge across the boundary
  return <ClientComp name="Page A" initialCount={count} />;
}

// Client Component
"use client";
export function ClientComp({ name, initialCount }: Props) {
  const [count, setCount] = useState(initialCount);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Next.js creates separate module graphs for Server Components and Client Components. Even if they import the same file, they get independent module instances with isolated state.

  2. "use client" is a bundler boundary, not just a rendering directive. It splits your dependency tree into two (or three) separate worlds.

  3. The RSC renderer never executes Client Components. It serializes a reference with props and hands it off to the client bundle for SSR and hydration.

  4. SSR and hydration use the same code but are separate executions. The client bundle runs once on the server (for HTML) and once in the browser (for hydration). Each execution initializes module state fresh.

  5. No hydration error doesn't mean correct architecture. Both SSR and the browser produce count = 0 independently โ€” they match, so React is happy. But the data from your Server Component (count = 1) was silently lost.

  6. Props are the only bridge between Server and Client Components. Anything you compute or fetch on the server must be passed as serializable props to be visible on the client.

  7. Mutable module-level state in Server Components is dangerous โ€” it persists across requests and can leak between users. Use it only for truly global, non-sensitive configuration.


Further Reading


Top comments (0)