Node has two module systems. CommonJS (CJS) is the original — require() and module.exports. ES Modules (ESM) is the standard from the JS spec — import and export. Knowing how they differ matters because mixing them up causes very real production bugs.
The two systems at a glance
How Node decides which system a file is
The rules in order:
- File ends in
.cjs→ CommonJS. - File ends in
.mjs→ ESM. - File ends in
.js→ look at the nearestpackage.json:"type": "module"→ ESM"type": "commonjs"or notypefield → CommonJS
// package.json
{
"type": "module"
}
With that, every .js file in the package is treated as ESM. If we still need a CJS file inside, we use .cjs.
Syntax side by side
// CommonJS
const fs = require("node:fs");
const { readFile } = require("node:fs/promises");
function greet(name) {
return `Hello, ${name}`;
}
module.exports = { greet };
// or: module.exports.greet = greet;
// ES Modules
import fs from "node:fs";
import { readFile } from "node:fs/promises";
export function greet(name) {
return `Hello, ${name}`;
}
// or default export:
// export default greet;
The gotchas
1. __dirname doesn’t exist in ESM
In CJS, __dirname and __filename are free variables. In ESM, they’re gone. We use import.meta.url:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Node 20.11+ added import.meta.dirname and import.meta.filename so we can skip the boilerplate.
2. ESM imports MUST include the extension
CJS lets us write require("./utils") and it tries .js, .json, .node. ESM is strict — we have to write ./utils.js. (Node 22+ has a --experimental-default-type flag and there’s ongoing work to relax this for node_modules.)
3. You can require() ESM (sometimes)
Until recently, require() of an ESM file threw ERR_REQUIRE_ESM. Node 22+ supports require() of synchronous ESM (no top-level await) under a flag, and Node 23+ enables it by default. Older versions force us to use dynamic import():
// In a CJS file, loading an ESM module:
async function load() {
const mod = await import("./esm-module.mjs");
mod.doStuff();
}
4. Named exports from CJS into ESM
Importing a CJS module from ESM gives us the whole module.exports as the default. Node tries to detect named exports too, but if it can’t (e.g., they’re set dynamically), we have to destructure manually:
// CJS package
import pkg from "lodash";
const { debounce } = pkg;
// or, if Node detects named exports:
import { debounce } from "lodash";
5. JSON imports need an attribute
import data from "./data.json" with { type: "json" };
Dual packages — supporting both
Library authors often ship both. The package.json exports field is how we tell Node which file to use:
{
"name": "my-lib",
"type": "module",
"main": "./dist/index.cjs",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
This is called conditional exports. The import key wins for ESM consumers, require wins for CJS.
When to use which
For new code in 2026, prefer ESM. It’s the standard, bundlers prefer it, top-level await is great, tree-shaking works better. The only reason to stay on CJS is a large existing codebase or a hot-loaded plugin system that needs sync require.