React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

nodejsesmcommonjsmodulespackage-json

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.

Recipe

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.

Working Example

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:

  • An ESM entry file importing a modern ESM-only package (chalk v5+)
  • createRequire from node:module to load a CommonJS-only package from ESM
  • The import.meta.url + fileURLToPath dance to rebuild __dirname
  • Reading JSON via fs to avoid the still-evolving import attributes syntax
  • Top-level await — only legal in ESM

Deep Dive

How It Works

CommonJS 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:

  1. Explicit extension — .mjs is always ESM, .cjs is always CommonJS.
  2. Otherwise, the nearest parent 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.

Variations

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));

TypeScript Notes

For TypeScript, "module": "NodeNext" is almost always the right choice for Node.js code. It:

  • Respects the nearest package.json "type" field.
  • Enforces .js extensions on relative imports (even though the source is .ts).
  • Understands the exports conditions (import, require, types).
  • Distinguishes .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.

Gotchas

  1. Forgetting file extensions in ESM imports. ESM requires the full specifier, including .js. import './foo' throws ERR_MODULE_NOT_FOUND. TypeScript with NodeNext enforces this at compile time.
  2. Mixing module types in one project. A .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.
  3. JSON imports need import attributes. 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(...)).
  4. 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.
  5. __dirname and __filename are undefined in ESM. Reconstruct them from import.meta.url with fileURLToPath.
  6. 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.
  7. "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.

Alternatives

Runtime / OptionStrengthsWeaknesses
Node.js ESMStandard, future-proof, top-level awaitStrict extensions, newer for some libraries
Node.js CJSMature, works with every libraryNo top-level await, no tree-shaking
Dual-package (ESM + CJS)Supports every consumerComplex build setup, easy to ship broken types
BunSupports both module systems natively, very fastDifferent runtime, ecosystem still maturing
DenoESM-only, secure by default, native TSDifferent module resolution, URL-based imports
Legacy CJS-only projectZero migration costCannot use modern ESM-only libraries statically

FAQs

How do I tell Node.js a file is ESM?

Either name it .mjs, or add "type": "module" to the nearest package.json. Otherwise Node treats .js files as CommonJS.

Can I use require in an ESM file?

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.

Can I use import in a CommonJS file?

Only the dynamic form: const mod = await import('some-pkg'). Static import statements only work in ESM files.

What is the difference between .mjs, .cjs, and .js?

.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.

Why do I get ERR_REQUIRE_ESM? (gotcha)

Because CommonJS require() cannot load an ESM package synchronously. Either convert your file to ESM, or use dynamic import() which returns a promise.

Why does my import fail with ERR_MODULE_NOT_FOUND? (gotcha)

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.

Should I set "module": "NodeNext" or "ES2022" in tsconfig.json? (typescript)

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.

Why does TypeScript want me to import ./foo.js when the file is ./foo.ts? (typescript)

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.

How do I get __dirname inside an ESM script?

Reconstruct it from import.meta.url:

import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
How do I import a JSON file in ESM?

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.

Does top-level await work in CommonJS?

No. Top-level await is an ESM-only feature. In CommonJS you must wrap async code in an async function and call it.

What is the exports field in package.json?

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.