Concept
CommonJS (CJS)
CommonJS was created for Node.js (2009) before there was a standard module system. It's synchronous, dynamic, and built into Node.js without any config.
// math.js, CJS export
const add = (a, b) => a + b;
const PI = 3.14159;
module.exports = { add, PI };
// or individually:
module.exports.add = add;
// Or using the shorthand:
exports.multiply = (a, b) => a * b;// app.js, CJS import
const { add, PI } = require('./math');
const math = require('./math'); // the whole exports object
// Dynamic, can conditionally require
if (process.env.DEBUG) {
const debug = require('./debug-utils'); // loaded at runtime
}CJS characteristics:
require()is synchronous, it blocks while reading the file from disk- The module is executed when first
require()'d; subsequent calls return the cached export module.exportsis an object, you can add to it anywhere in the file- Imports are resolved at runtime, can be inside
if,for, function calls - Not natively understood by browsers (bundlers handle it)
ES Modules (ESM)
ES Modules are the JavaScript standard (ES2015 / ES6), now supported natively in all modern browsers and Node.js 12+.
// math.js, ESM export
export const add = (a, b) => a + b;
export const PI = 3.14159;
export default function multiply(a, b) { return a * b; }// app.js, ESM import
import { add, PI } from './math.js'; // named imports
import multiply from './math.js'; // default import
import * as math from './math.js'; // namespace import
// Dynamic import, returns a Promise
const { add } = await import('./math.js');ESM characteristics:
import/exportare parsed statically at parse time (not runtime)- Imports are live read-only bindings to the exported value, not copies
importstatements are hoisted; module evaluation is async- Circular dependencies are handled differently (live bindings vs cached exports)
- Files must have
.mjsextension or"type": "module"inpackage.jsonfor Node.js to treat them as ESM - In browsers:
<script type="module">enables native ESM
Why static imports matter
ESM's static structure enables tools to know the full import graph at build time, before any code runs. This is what makes tree shaking possible. With CJS:
// CJS, impossible to statically analyse
const moduleName = condition ? 'lib-a' : 'lib-b';
const lib = require(moduleName); // which exports are used? unknown at build timeWith ESM, all imports are at the top level and can't be conditional:
// ESM, static, analysable
import { specificExport } from './lib.js'; // bundler knows exactly what's usedThe dual module problem
Many npm packages need to work in both Node.js (CJS) and bundlers (ESM). The solution: publish both formats.
{
"name": "my-lib",
"main": "./dist/index.cjs.js", // CJS entry (Node.js require())
"module": "./dist/index.esm.js", // ESM entry (bundlers)
"exports": {
".": {
"import": "./dist/index.esm.js", // ESM, used by import
"require": "./dist/index.cjs.js" // CJS, used by require
}
}
}The exports field (Node.js 12+) is the modern way. main and module are legacy but still widely used.
Node.js and the .mjs/.cjs file extension
Node.js determines module type by:
- File extension:
.mjs= ESM,.cjs= CJS - Nearest
package.json's"type"field:"type": "module"= all.jsfiles are ESM;"type": "commonjs"(default) = all.jsfiles are CJS
# This fails if the file uses ESM and type isn't set:
node app.js # Error: Cannot use import statement in a module
# Fix options:
# 1. Rename to app.mjs
# 2. Add "type": "module" to package.json
# 3. Use dynamic import() from CJS: const mod = await import('./esm-module.mjs')Interop: using ESM from CJS and vice versa
CJS can require() another CJS file: always works.
ESM can import CJS: works, but you only get the default export (the whole module.exports object). Named exports from CJS files don't exist in ESM, the entire export is the default:
// cjs-lib.js
module.exports = { foo: 1, bar: 2 };
// esm-consumer.mjs
import cjsLib from './cjs-lib.js';
console.log(cjsLib.foo); // 1, works, but not named imports
import { foo } from './cjs-lib.js'; // May work in bundlers (they analyze CJS), not in Node.jsCJS cannot require() ESM: this is the ERR_REQUIRE_ESM error. CJS require() is synchronous; ESM evaluation is async. They're fundamentally incompatible.
// This throws ERR_REQUIRE_ESM:
const esmLib = require('./esm-lib.mjs');
// Fix: use dynamic import(), works in CJS (it returns a Promise)
const esmLib = await import('./esm-lib.mjs');Common Mistakes
1. Mixing require and import in the same file
Node.js enforces one module system per file. import in a .js file without "type": "module" throws a SyntaxError. require in an .mjs file throws ReferenceError.
2. Forgetting .js extensions in ESM imports
Node.js (and native browser ESM) require full file extensions. Bundlers are lenient and resolve ./utils → ./utils.ts. But native Node.js ESM: import './utils' fails; import './utils.js' works.
3. Using module.exports = ... then expecting named imports to work in ESM
// CJS: module.exports = { a, b }
// ESM consumer (in a bundler):
import { a, b } from './lib.cjs'; // bundlers handle this
// In Node.js native ESM:
import lib from './lib.cjs'; // only default import works
const { a, b } = lib; // destructure manually4. Not setting the exports field in a library's package.json
Without exports, Node.js resolves to main (CJS). Bundlers look at module (ESM). Without exports, deep imports (import { something } from 'mylib/utils') may break because you haven't explicitly allowed them.
5. Top-level await in a CJS context
Top-level await is an ESM-only feature. In CJS, wrap in an async IIFE.
Best Practices
- New projects/packages: use ESM (
"type": "module"inpackage.json). It's the standard. - Libraries: publish dual CJS+ESM using the
exportsfield. Usetsupto automate this:tsup src/index.ts --format cjs,esm --dts. - Always include file extensions in ESM imports when targeting Node.js (not bundlers).
- Use
"exports"inpackage.json, it's the authoritative module resolution map and lets you block deep imports or add conditions.
