ESM vs CommonJS Modules
Understanding Node.js's two module systems - when to use each, how to migrate, and how to support both consumers from a single package.
Search across all documentation pages
Understanding Node.js's two module systems - when to use each, how to migrate, and how to support both consumers from a single package.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
// package.json - declare this package as ESM
{
"name": "my-pkg",
"version": "1.0.0",
"type": "module"
}// ESM - import / export
import { readFile } from 'node:fs/promises';
export async function loadConfig(path) {
return JSON.parse(await readFile(path, 'utf8'));
}// CommonJS - require / module.exports
const { readFileSync } = require('node:fs');
module.exports.loadConfig = function (path) {
return JSON.parse(readFileSync(path, 'utf8'));
};// Dynamic import - works in BOTH systems
const mod = await import('some-esm-only-package');// Use createRequire to load CJS from ESM
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacy = require('some-cjs-only-package');Explicit extensions:
.mjs - always ESM, regardless of package.json..cjs - always CommonJS, regardless of package.json..js - depends on the nearest package.json's "type" field.When to reach for this: Any time you hit an "ERR_REQUIRE_ESM" error, a "Cannot use import statement outside a module" error, or you are deciding how to publish a package.
An ESM script that imports both a modern ESM package and a legacy CJS package.
// package.json
{
"name": "mixed-modules",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"chalk": "^5.3.0",
"lodash": "^4.17.21"
}
}// src/index.js - ESM entry that mixes both module systems
import chalk from 'chalk'; // ESM-only package
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { readFile } from 'node:fs/promises';
// Reconstruct __dirname in ESM
const __dirname = dirname(fileURLToPath(import.meta.url));
// Load a CommonJS-only package from an ESM file
const require = createRequire(import.meta.url);
const _ = require('lodash');
// Load JSON in ESM (import attributes syntax)
const pkg = JSON.parse(
await readFile(join(__dirname, '..', 'package.json'), 'utf8'),
);
console.log(chalk.green(`Running ${pkg.name} v${pkg.version}`));
console.log(chalk.cyan('chunked:'), _.chunk([1, 2, 3, 4, 5], 2));What this demonstrates:
chalk v5+)createRequire from node:module to load a CommonJS-only package from ESMimport.meta.url + fileURLToPath dance to rebuild __dirnamefs to avoid the still-evolving import attributes syntaxawait - only legal in ESMCommonJS was Node.js's original module system. Every .js file is wrapped in a synchronous function that receives require, module, exports, __filename, and __dirname. Because it is synchronous, require('x') blocks until x is fully loaded.
ES Modules are the JavaScript standard. They are asynchronous, statically analyzable, and support features CommonJS never could - top-level await, tree-shaking, and live bindings. The trade-off is that require is not available inside ESM scope, file extensions are mandatory, and the module graph is resolved before any code runs.
Node.js picks a module system per file based on:
.mjs is always ESM, .cjs is always CommonJS.package.json's "type" field - "module" means ESM, "commonjs" (or missing) means CommonJS.From ESM you can always load CommonJS with a default import or createRequire. From CommonJS you cannot statically require an ESM module - you must use dynamic import(), which returns a promise.
Converting a CJS file to ESM:
// Before - CommonJS
const path = require('node:path');
const { readFile } = require('node:fs/promises');
module.exports.read = (p) => readFile(path.resolve(p), 'utf8');
// After - ESM
import path from 'node:path';
import { readFile } from 'node:fs/promises';
export const read = (p) => readFile(path.resolve(p), 'utf8');Dual-package exports - ship both ESM and CJS:
// package.json
{
"name": "dual-pkg",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}Importing JSON in ESM - use the import attributes syntax (escaping the braces here because this is markdown prose): import data from './data.json' with \{ type: 'json' \};. It is behind a flag in older Node versions, so many projects still prefer readFile + JSON.parse.
ESM __dirname polyfill:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));For TypeScript, "module": "NodeNext" is almost always the right choice for Node.js code. It:
package.json "type" field..js extensions on relative imports (even though the source is .ts).exports conditions (import, require, types)..mts (forces ESM) and .cts (forces CJS)."module": "ES2022" compiles to ESM but does not enforce Node's resolution rules - use it only if a bundler will consume the output.
Always pair "module": "NodeNext" with "moduleResolution": "NodeNext". Any other resolution mode will silently diverge from how Node actually loads the files.
.js. import './foo' throws ERR_MODULE_NOT_FOUND. TypeScript with NodeNext enforces this at compile time..js file in a "type": "module" package is ESM; its sibling .cjs file is CommonJS. This works, but naming and imports get confusing fast - prefer one system per package.import data from './data.json' throws in modern Node unless you add with { type: 'json' } (escaped here because markdown). When in doubt, JSON.parse(await readFile(...)).require is not available in ESM scope. Trying to call require('x') inside an ESM file throws ReferenceError: require is not defined. Use createRequire(import.meta.url) when you really need it.__dirname and __filename are undefined in ESM. Reconstruct them from import.meta.url with fileURLToPath.require()-ing an ESM package. You get ERR_REQUIRE_ESM. Migrate to ESM or use a dynamic import(), which is async and returns a promise."type": "module" affects every .js file under it. Adding the field flips an entire project at once. Rename anything that must stay CJS to .cjs.
| Runtime / Option | Strengths | Weaknesses |
|---|---|---|
| Node.js ESM | Standard, future-proof, top-level await | Strict extensions, newer for some libraries |
| Node.js CJS | Mature, works with every library | No top-level await, no tree-shaking |
| Dual-package (ESM + CJS) | Supports every consumer | Complex build setup, easy to ship broken types |
| Bun | Supports both module systems natively, very fast | Different runtime, ecosystem still maturing |
| Deno | ESM-only, secure by default, native TS | Different module resolution, URL-based imports |
| Legacy CJS-only project | Zero migration cost | Cannot use modern ESM-only libraries statically |
Either name it .mjs, or add "type": "module" to the nearest package.json. Otherwise Node treats .js files as CommonJS.
Not directly. require is a CommonJS-only global. Use createRequire(import.meta.url) from node:module if you must load a CJS module from ESM.
Only the dynamic form: const mod = await import('some-pkg'). Static import statements only work in ESM files.
.mjs is always ESM, .cjs is always CommonJS, and .js depends on the nearest package.json's "type" field. If "type" is missing, .js defaults to CommonJS.
Because CommonJS require() cannot load an ESM package synchronously. Either convert your file to ESM, or use dynamic import() which returns a promise.
Almost always a missing file extension. ESM requires the full specifier - write ./foo.js, not ./foo. CommonJS is more forgiving and adds the extension for you.
Use "NodeNext" for any code that Node.js will run directly. It enforces Node's real resolution rules, including mandatory .js extensions and the exports field. Use "ES2022" only when a bundler (Vite, webpack, esbuild) processes the output.
Because NodeNext models the runtime specifier. At runtime Node loads foo.js - either compiled from foo.ts, or resolved back to foo.ts by tsx/ts-node. Writing ./foo.js is the only form that works in both cases.
Reconstruct it from import.meta.url:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));Use import attributes - import data from './data.json' with { type: 'json' } - or read and parse it yourself with fs.readFile and JSON.parse. The latter avoids flag compatibility issues across Node versions.
No. Top-level await is an ESM-only feature. In CommonJS you must wrap async code in an async function and call it.
It is the modern way to declare a package's public entry points. It supports conditional exports - different files for import, require, types, browser, and so on - and it hides files you do not want consumers to reach into.
Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥