The Module System as a Mental Model

Before we can talk about React Server Components (RSC), we need to talk about module systems. Computers don't need modules; they just need code and data loaded into memory. Modules exist for humans: they let us break programs into digestible pieces, hide implementation details, and reuse code.

A module system is the bridge between how humans author split-up code and how computers execute unrolled code. In JavaScript, that bridge is the import and export keywords.

It's tempting to think of imports as a form of copy-paste. If a.js defines foo() and b.js defines bar(), then a module that imports both behaves like a single file containing both functions. By the time a program runs, it's been "unrolled" in memory just as if you'd pasted everything into one file.

But that's only roughly true. The historical alternative—C's #include directive—was literally copy-paste. It embedded file contents verbatim, which meant name clashes between unrelated files were a real problem, and the same file could be included multiple times if it was referenced from several places. Developers had to add manual guards to prevent double inclusion.

Every Module Is a Singleton

JavaScript modules solve that second problem automatically. If both a.js and b.js import from c.js, the code in c.js executes only once—not twice. The module system keeps a cache of loaded modules keyed by filename, so any subsequent import of the same module reads exports from that cache instead of re-running the module body.

This singleton behavior has three important consequences:

  • Bundled output doesn't explode in size from repetition.
  • Top-level variables in a module maintain private state across all importers.
  • The mental model stays simple: a module's top-level code runs at most once.

This design works well when a program runs on a single computer—a browser, a server, or any one runtime. There's an entry point, that entry imports other modules, those import more, and so on. The module cache ensures each file is loaded once and its exports are available to everyone who imports it.

Two Computers, Two Module Systems

The trouble starts when you want a JavaScript backend and a JavaScript frontend. These are two separate programs running on two separate computers, and each has its own independent module system.

If backend/index.js imports a.js and b.js, those files become part of the backend program. If frontend/index.js also imports them, those same files become part of the frontend program. You're reusing the code, sure—but it's more accurate to say each side has its own copy of those modules.

This has always been how full-stack apps work. Reuse code that's safe for both sides, and don't accidentally reuse code that isn't.

Build Failures as a Safety Net

Consider a module that imports Node's fs to read a file. That's fine for the backend build. The frontend build will fail, because fs doesn't exist in the browser. And that's actually a good outcome—you want problems like this discovered as early as possible, not at runtime.

When a shared module gets poisoned with an environment-specific dependency, you have several options:

  1. Move the problematic code out of the shared module.
  2. Refactor the importers so they no longer depend on that module.
  3. Change the entry point so it doesn't need those modules at all.

All of these approaches are valid. The right one depends on what you're trying to accomplish—it's like resolving a Git conflict. There are errors to fix and decisions to make, and that's fine.

Poison Pills for Environment-Specific Code

A build failure from fs is nice, but its presence in a module doesn't guarantee it'll always be caught. Worse scenarios exist. Suppose a shared module imports a server-side secret. That would silently become part of both the backend and frontend builds if nothing blocks it. Many full-stack setups offer no protection against that.

The fix is a marker module called server-only. It contains no actual code—it's a "poison pill" designed to fail the frontend build when imported. After marking a secrets.js file with import 'server-only', any frontend module that transitively imports secrets.js will trigger a build error. The protection propagates up the import chain automatically, so you only need to mark the files that truly must never cross over.

There's a mirror image too: client-only, which fails the server-side build. It catches attempts to pull browser-specific APIs into backend code. These two assertions don't control where code runs—they only prevent code from being pulled into an environment it can't work in.

The principle generalizes. A package like React could mark useState and useEffect as client-only, so importing them in server code fails immediately. React does exactly this using package.json Conditional Exports, but the conceptual tool is the same.

Doors Between the Two Worlds

Poison pills prevent mistakes, and they give you a safer way to share code between backend and frontend than most setups offer. But one weakness remains: backend and frontend modules can't refer to each other.

The backend might need to reference a component that runs on the frontend—for data fetching, say—but importing that component into the backend would pull its code into the backend bundle. That's wrong. You want a reference, not a copy.

That's the last piece RSC provides. The 'use client' directive changes what an import means. Importing a 'use client' module from backend code doesn't bring its code into the backend; instead it provides a reference that React can eventually turn into a <script> tag for the frontend. Likewise, 'use server' lets frontend code hold a reference to a backend module without bundling that module's code into the frontend.

These directives aren't about marking where code runs. Putting 'use client' in every frontend module is pointless. They're doors between the two module systems, letting one side pass data to the other without pulling the other side's code into itself.

Putting the Model Together

RSC builds directly on the familiar model of two independent module systems, one per environment, with shared code that's effectively duplicated by the module semantics of each side. RSC adds two mechanisms on top:

  • import 'client-only' and import 'server-only', declarations that certain modules must never cross the environment boundary.
  • 'use client' and 'use server', directives to refer to modules from the other world while leaving the code where it belongs.

With those tools, an RSC application is a single program spread across two computers. There are two module systems, two poison pills that enforce which code stays where, and two directions in which information can pass. The frontend/backend directory split starts to feel obsolete because each module carries its environment constraints locally. As the codebase evolves, boundaries shift automatically as imports change.

The core idea is simple: use regular imports when you want code brought into your current world, use poison pills to prevent that from happening accidentally in the wrong environment, and use directives to reference code in the other world without absorbing it. The rest is just resolving the build errors.