1:I["./components/LikeButton.js",["chunks/main.js"],"default"]

A module path, a chunk, an export name. That's bundler output — the bundler assigned the ID and decided which file the module lives in.

Which means React's server renderer cannot produce a payload without knowing what the bundler did.

Sit with that for a second, because it's genuinely strange. React is a UI library. Bundlers are build tooling. For twenty years the relationship was one-directional: you wrote React, a bundler packaged it, and React neither knew nor cared what happened afterwards.

RSC breaks that. The server render depends on build output to function at all.

Most people accept "React needs a bundler now" as trivia rather than noticing it's a genuinely unusual coupling.


What "use client" compiles to

Post 3 said the directive marks a boundary rather than a location. Here's the mechanical version.

When the bundler builds the server graph and hits a module with "use client" at the top, it doesn't compile that module into the server bundle. It substitutes a stand-in — an object carrying the module's ID, its chunk, and the export name. Every import of LikeButton in server code resolves to that stand-in.

So on the server, LikeButton is not a function. It's a small object that says here is where the real thing lives. When the renderer encounters it in the tree, it can't call it — there's nothing to call — so it writes the address into the payload and moves on.

That's the whole trick. The directive is an instruction to the bundler to not include this code here, and leave a forwarding note instead.

And it explains a detail from post 3 that otherwise looks arbitrary. Props passed to a client component have to be serializable — not because React is being fussy, but because those props are going into a text payload alongside an address. There's no function on the other side to receive a closure.


Two graphs

If the server gets a stand-in and the browser gets the real module, then the same file is being treated two different ways in one build. That's not a metaphor. There are two module graphs.

The server graph starts at your routes and layouts. It contains every Server Component, every server-side utility, your database client, your secrets. "use client" modules appear in it only as stand-ins.

The client graph starts at every "use client" module. Those are its entry points. From each one it follows imports outward, and everything reachable ends up in a chunk the browser downloads.

One repo. One src directory. Two graphs built from it, with different entry points and different rules.

Turbopack, the default since Next 16, doesn't literally run two separate compilers the way webpack did — it keeps a single logical graph that understands multiple output environments, and resolves a module differently depending on which environment is pulling it in. Same outcome, less duplicated work.

The mechanism: export conditions

Post 4 mentioned conditions during resolution — import versus require, browser versus node. There's another one, and it's what makes two graphs possible: react-server.

A package can ship different files for different conditions:

{
  "exports": {
    ".": {
      "react-server": "./dist/server.js",
      "default": "./dist/client.js"
    }
  }
}

The react-server condition is active while building the server graph and inactive while building the client graph. So import { thing } from 'some-package' in one file can resolve to two entirely different files depending on which graph is asking.

That isn't an edge case. It's how React itself ships — react resolves to a different build under the react-server condition, one where useState doesn't exist. Server Components can't use hooks because in that graph, the hooks aren't there.

[YOUR VOICE: "the hooks aren't there" is a much more satisfying answer than "server components don't support hooks." Worth a line on which explanation you'd been carrying around.]


The manifest

Two graphs need a phone book between them. That's the client manifest — a JSON file the bundler writes at build time, mapping module IDs to the chunks that contain them and the exports they provide.

It gets read twice, by two different consumers, exactly as post 3 described:

  • The SSR pass, in Node, resolving an address to a module in .next/server/ so it can render markup

  • The browser, at hydration, resolving the same address to a downloaded chunk

Same address, two manifests, two lookups.

This is the concrete answer to "why does RSC need a bundler." Not for performance. Not for convenience. The protocol contains bundler identifiers, and nothing can consume it without the table that decodes them.


Imports across the boundary

Two graphs, so imports can cross in two directions. They behave completely differently, and only one of them is safe.

Server importing client: fine

// app/page.tsx — server
import { LikeButton } from './LikeButton'  // "use client"

This is the normal case. The bundler swaps in a stand-in, the payload gets an address, everything works.

Client importing server: this is where it goes wrong

// components/LikeButton.tsx — "use client"
import { getUser } from '../lib/db'   // no directive

db.ts has no directive. So the bundler, walking outward from a client entry point, treats it as client code — and pulls it into the client graph.

Your database module is now in a browser chunk.

Sometimes this fails loudly: db.ts imports fs, there's no fs in a browser, the build breaks and you go fix it. That's the good outcome.

Sometimes it doesn't fail at all. The module is pure JavaScript, it bundles cleanly, and if it read process.env.DATABASE_URL at module scope, that value is now a string literal in a file anyone can download.

A file with no directive isn't "server code." It's code that belongs to whichever graph imports it. Server Components are unambiguous because they're only reachable from the server entry points. A utility module is whatever pulls it in.

[YOUR VOICE: have you ever checked? Most people haven't, and the honest answer is more useful than a warning.]

server-only, and how it actually works

The fix is a package that makes the mistake loud:

import 'server-only'

The implementation is a neat trick and worth knowing because it teaches you conditions properly. The package ships two files. Under the react-server condition it resolves to an empty module that does nothing. Under any other condition it resolves to a module that throws.

So in the server graph the import evaporates. In the client graph it explodes at build time with a message naming the file.

There's a client-only package doing the mirror image, for modules that touch window or document and should never end up in a server render.

One line at the top of any module holding secrets, database access, or server-side keys. Cheap insurance against the silent version of the failure.


Where you put the directive decides what you ship

Here's the part with real consequences.

Every module reachable from a "use client" entry point ends up in the client graph. Not just the component with the directive — everything it imports, and everything those import, all the way down.

So the directive isn't a label on one file. It's the root of a subtree.

Put it at the top of a layout that wraps your whole app, and every component beneath it is client code, in a chunk, downloaded by every visitor. The app still works. Nothing errors. You've just quietly shipped your entire application to the browser and given up most of what RSC was for.

The barrel file version

The modules post covered why barrel files defeat tree shaking. There's a sharper version of the problem at the boundary.

// components/index.ts
export * from './Button'      // "use client"
export * from './Modal'       // "use client"
export * from './DataTable'   // "use client"
// ...forty more
// app/page.tsx — server
import { Button } from '@/components'

You imported one button. But that index module is now an entry into the client graph, and depending on how much the bundler can prove is unused, you may have dragged a large part of that directory in behind it.

Import from the module directly and the problem disappears:

import { Button } from '@/components/Button'

Push the boundary down

The general fix is to make the client subtree as small as it can be. A page with one interactive button doesn't need to be a client component — the button does.

// ❌ everything below is client
'use client'
export default function Page() {
  const [open, setOpen] = useState(false)
  return (
    <article>
      <ExpensiveMarkdownRenderer content={content} />
      <button onClick={() => setOpen(!open)}>Toggle</button>
    </article>
  )
}
// ✅ only the button is client
export default function Page() {
  return (
    <article>
      <ExpensiveMarkdownRenderer content={content} />
      <ToggleButton />
    </article>
  )
}

Same UI. In the second version the markdown renderer and its dependencies never enter the client graph.

Children, and the bundling reason it works

Post 3 covered why you can pass a Server Component into a Client Component as children — the server already rendered it, so what arrives is finished output. Here's the bundling half of that answer.

// server
<ClientShell>
  <ServerContent />
</ClientShell>

ClientShell never imports ServerContent. There's no import statement, so there's no edge in the graph, so ServerContent is never reachable from a client entry point. It stays out of the client bundle entirely.

That's why the pattern isn't a workaround. It's the boundary working exactly as designed: imports pull things into your bundle; props don't.


Third-party packages with no directive

A library published before RSC, or one that just hasn't updated, has no "use client" anywhere. If it uses hooks or browser APIs and you import it from a Server Component, the build fails.

Wrap it:

// components/Carousel.tsx
'use client'
export { Carousel } from 'some-carousel-library'

Now your one-line module is the boundary, and the library sits behind it in the client graph. Import your wrapper from server code and everything resolves.

You'll write a few of these. It's a normal part of an App Router codebase, not a sign anything is wrong.


Go and look

Twenty minutes on your own app, and it will teach you more than the rest of this post.

Find your client entry points. Search the repo for "use client". Look at where each one sits in the tree — is it a leaf, or is it wrapping half your app?

Check for a boundary that's too high. Any directive in a layout.tsx, or in a page component that mostly renders static content, is a candidate. Ask what's underneath it.

Look for a client component importing something that touches your server. Database clients, API keys, anything reading process.env at module scope.

Then add import 'server-only' to those modules and rebuild. If the build breaks, you've found a real leak, and you found it before a user did.


What's next

You now have the full picture of how code gets divided: what runs where, when it runs, and how the pieces find each other.

What's missing is time. Everything so far has been described as if the server produces a payload in one go. It doesn't — it can send a tree with pieces still missing, and fill them in later on the same response.

That's Suspense, and almost everything people believe about it is wrong.


If this helped, or if I got something wrong — find me on LinkedIn or my site. I read everything.