---
title: "02 — Node.js Modules — Splitting Code into Files"
uid: nodejs-modules
tags: ["require", "esm", "nodejs", "import", "modules", "roadmap:nodejs", "commonjs"]
excerpt: "A module is just a file with its own scope, and Node has two systems for sharing code between files — CommonJS (the legacy default) and ESM (the modern standard). The seam between them is today's friction."
date: 2026-08-13T03:27:57+0000
source: https://www.aveshina.my.id/en/blog/nodejs-modules
---

"Just import what you need" was my modules mental model, and it skipped the split that explains most Node errors. The idea that everything else hangs off: **a module is just a file.** Node.js gives every file its own scope, and provides a module system so files can share their internals with each other. There are two such systems, and the friction in the Node.js ecosystem today is almost entirely the seam between them [1].

The framing that finally landed for me is the contrast — not the syntax in isolation.

## CommonJS: the legacy default

CommonJS is the module system Node.js shipped with. It uses require() to import and module.exports (or exports) to export, and it is **synchronous and dynamic** — require is a real function that runs when the line executes, so the module is loaded at runtime [1][2].

```
// math.js — CommonJS
function add(a, b) {
  return a + b;
}

module.exports = { add };
```

```
// app.js — CommonJS
const { add } = require('./math');
console.log(add(2, 3)); // 5
```

The whole file's exports live on the module.exports object, and require returns whatever that object is. The path matters — ./math is a local file, express (no dot) resolves through node_modules. CommonJS is still the default in any file that isn't explicitly flagged otherwise, and an enormous amount of existing Node.js code is written this way.

## ESM: the modern standard

ESM (ECMAScript Modules) is the official JavaScript module standard, the same one browsers use. It uses import and export keywords, and it is **static** — imports are analyzed at parse time, before any code runs. That enables tree-shaking (dropping unused exports), better tooling, and it always runs in strict mode [3][4].

```
// math.mjs — ESM
export function add(a, b) {
  return a + b;
}
```

```
// app.mjs — ESM
import { add } from './math.mjs';
console.log(add(2, 3)); // 5
```

The two systems look similar but behave differently in ways that bite. ESM imports are **live bindings** — the imported name always reflects the current value in the exporting module. ESM imports are **hoisted** — they happen before the rest of the file executes, regardless of where the line sits. And ESM is **asynchronous** by design, which is why require and import cannot be freely mixed.

## How Node.js decides which system to use

The friction in practice is "which system is this file?" Node.js uses a set of rules:

- A .mjs file is always ESM. A .cjs file is always CommonJS.
- A .js file's type depends on the nearest package.json — if it has "type": "module", the file is ESM; otherwise it is CommonJS by default [3].
- import in a CommonJS file throws; require of an ESM file throws.

```
{
  "type": "module"
}
```

That one line in package.json flips every .js file in the package to ESM. New projects I start today default to ESM; the migration story for older CommonJS projects is real work because the two systems interop only through dynamic import() and createRequire.

## Creating and importing: how it works

The model I keep is simple — **every file is a module, and every module is wrapped in a function by Node before it runs.** That wrapper is why the top level of a Node file is not the global scope. It is also why __dirname, __filename, require, module, and exports are available without me importing them — Node injects them as arguments to the wrapper.

```
// roughly what Node does with every CommonJS module
(function (exports, require, module, __filename, __dirname) {
  // my module code lives here
});
```

That wrapper is the reason a top-level var in a Node module does not leak into other files. In a browser, var x at the top level becomes a property of window. In Node, it becomes a local variable inside the wrapper function [5].

## The global keyword

The counterpart to the browser's window is Node's global object. Anything attached to global is visible in every module without an import — global.myThing = 1 is then available as myThing everywhere [5]. This is a power tool I avoid reaching for. Globals make code harder to test, harder to reason about, and easy to break by accident. The legitimate globals Node provides (process, console, Buffer, the timers) are the ones the runtime itself depends on; user code almost always does better with explicit imports.

The one detail worth knowing: in ESM, some of the CommonJS-only globals (__dirname, __filename, require) are **not** available, because the ESM wrapper is different. The ESM equivalents come from import.meta.url.

```
// ESM equivalent of __dirname
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
```

That snippet is the price of admission to ESM for any tool that needs absolute file paths.

## How I use this

The decision check I use is "which system does this project live in?" — and I check package.json for "type": "module". For new code I default to ESM: it is the standard, it works in browsers and Node with the same syntax, and static analysis makes tooling better. For existing CommonJS projects I stay consistent rather than mixing freely, and I reach for dynamic import() only at the boundary. The model — a module is a wrapped file, two systems for sharing — is what keeps the friction manageable.

## References

[1] OpenJS Foundation, "Modules: CommonJS modules," Node.js API Docs. [Online]. Available: [https://nodejs.org/api/modules.html#modules-commonjs-modules](https://nodejs.org/api/modules.html#modules-commonjs-modules)

[2] O.坂本, "CommonJS vs. ES Modules in Node.js," LogRocket Blog, 2023. [Online]. Available: [https://blog.logrocket.com/commonjs-vs-es-modules-node-js/](https://blog.logrocket.com/commonjs-vs-es-modules-node-js/)

[3] OpenJS Foundation, "ECMAScript Modules," Node.js API Docs. [Online]. Available: [https://nodejs.org/api/esm.html](https://nodejs.org/api/esm.html)

[4] T. Eckert, "ES Modules in Node Today," LogRocket Blog. [Online]. Available: [https://blog.logrocket.com/es-modules-in-node-today/](https://blog.logrocket.com/es-modules-in-node-today/)

[5] OpenJS Foundation, "Globals," Node.js API Docs. [Online]. Available: [https://nodejs.org/api/globals.html](https://nodejs.org/api/globals.html)

```quiz
Q: What is the fundamental difference between CommonJS `require` and ESM `import`?
- require is synchronous and dynamic; import is static and hoisted, analyzed at parse time
- require only works for built-in modules; import works for everything
- There is no difference, just different syntax
correct: 0
explain: CommonJS require runs as a function at runtime (dynamic, synchronous). ESM imports are resolved before code executes (static, hoisted), which enables tree-shaking and better tooling.

Q: A `.js` file in a project with no `"type"` field in package.json is treated as…
- ESM
- CommonJS
correct: 1
explain: Without a "type": "module" field in the nearest package.json, .js files default to CommonJS. Adding "type": "module" flips them to ESM.

Q: Why does a top-level `var x = 1` in a Node module NOT leak to other files?
- Node wraps each module in a function, so top-level vars are local to that wrapper
- Node deletes top-level vars after the file loads
correct: 0
explain: Node wraps every module body in a function (passing exports, require, module, __filename, __dirname). Top-level vars are locals inside that wrapper, not properties of a global object like window.

Q: In an ESM file, how do you get the equivalent of CommonJS `__dirname`?
- It is available automatically, same as CommonJS
- Derive it from `import.meta.url` using fileURLToPath and dirname
correct: 1
explain: ESM's wrapper differs from CommonJS, so __dirname and __filename are not injected. The ESM equivalent is built from import.meta.url via the url and path modules.
```
