CJS vs ESM
Node has two module systems — CommonJS (require) and ECMAScript Modules (import). New projects should be ESM. Knowing how each resolves and interops with the other prevents the most common Node startup errors.
CJS + ESM + interop + resolution
EXAMPLE
// 1) ESM — the modern default
// package.json: { "type": "module" }
// math.js
export function add(a, b) { return a + b; }
export function mul(a, b) { return a * b; }
export const PI = 3.14159;
export default { add, mul };
// app.js
import defaultExport, { add, mul, PI } from './math.js';
import * as math from './math.js';
console.log(add(2, 3), math.mul(2, 3), PI);
// IMPORTANT: ESM requires file EXTENSIONS in relative imports.
// import x from './math' // ❌ ERR_MODULE_NOT_FOUND
// import x from './math.js' // ✓
// 2) CommonJS — older code, scripts, many tools still
// package.json: { "type": "commonjs" } (or no type field)
// math.cjs
function add(a, b) { return a + b; }
module.exports = { add };
module.exports.PI = 3.14159;
// app.cjs
const { add, PI } = require('./math.cjs');
const all = require('./math.cjs');
// CJS resolves bare paths and extensions automatically:
// require('./math') // finds math.js / math.cjs / math/index.js
// 3) Dynamic import — works in BOTH CJS and ESM
const { add } = await import('./math.js');
// Use case: conditional plugin loading, lazy heavy deps, top-level await in CJS scripts
// 4) ESM <-> CJS interop
// ESM importing CJS — fine, single default export
import pkg from 'some-cjs-lib';
const { foo, bar } = pkg; // destructure off the default
// or named (Node guesses when CJS sets module.exports = { foo, bar }):
import { foo, bar } from 'some-cjs-lib';
// CJS importing ESM — must be dynamic, ESM cannot be require()'d
const esm = await import('some-esm-lib');
// You CANNOT do: const x = require('some-esm-lib'); → ERR_REQUIRE_ESM
// 5) File extensions as a tell
// .mjs → always ESM, ignores package.json
// .cjs → always CommonJS, ignores package.json
// .js → whichever "type" says in the NEAREST package.json
// 6) Path resolution
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// __dirname / __filename don't exist in ESM. Rebuild them:
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 7) Node-specific imports — prefer the 'node:' prefix
import fs from 'node:fs/promises';
import http from 'node:http';
import { Buffer } from 'node:buffer';
// Explicit 'node:' avoids accidentally shadowing builtins with a userland package.
// 8) Package entry points (subpath exports)
// package.json
{
"name": "my-lib",
"type": "module",
"exports": {
".": { "import": "./dist/index.js", "require": "./dist/index.cjs", "types": "./dist/index.d.ts" },
"./helpers": { "import": "./dist/helpers.js" }
}
}
// Now consumers do: import x from 'my-lib' / import h from 'my-lib/helpers'
// Anything not listed in "exports" is INACCESSIBLE — deliberate encapsulation.
// 9) Top-level await — ESM only
// app.js (ESM)
const config = await fetch('https://config.example.com').then((r) => r.json());
export default config;
// In CJS, wrap in an async IIFE.
// 10) import.meta — ESM only
console.log(import.meta.url); // file:// URL of current module
import.meta.resolve('./other.js'); // absolute URL
// 11) Conditional exports per environment
{
"exports": {
".": {
"node": "./dist/node.js",
"browser": "./dist/browser.js",
"default": "./dist/index.js"
}
}
}
// Bundlers (webpack, vite, esbuild) read these to pick the right build.
// 12) Common errors and fixes
// ERR_MODULE_NOT_FOUND → add the file extension in the import
// ERR_REQUIRE_ESM → use await import(), or rewrite caller as ESM
// SyntaxError: Cannot use import... → set "type": "module" or rename to .mjs
// __dirname is not defined → rebuild from fileURLToPath(import.meta.url)
// Unexpected token 'export' → file is being loaded as CJS
// 13) Recommended setup for new projects
// • package.json: "type": "module"
// • use "node:" prefix for builtins
// • use "exports" map for public APIs
// • TypeScript: "module": "NodeNext", "moduleResolution": "NodeNext"
// • Always include extensions in relative imports
Why it matters
Default to ESM in new code; use node:-prefixed builtins; include the file extension in every relative import. When you must consume CJS-only packages, default-import them and destructure, and when CJS code must reach ESM, fall back to dynamic await import().
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// CommonJS
const fs = require('fs');
// ESM (set "type": "module" in package.json)
import fs from 'node:fs';
Try it Yourself »
Exercise
In package.json, opt in to ESM with…
"type": "
"
Six letters.
Discussion
Loading…