Modules: ES modules vs CommonJS

Node.js · Essentials · 14 min

Every Node file is one or the other, and mixing them wrongly is the most common source of confusing startup errors.

Node has two module systems:

  • ES modules (ESM) — the JavaScript standard: import / export, top-level await, imports are resolved before any code runs.
  • CommonJS (CJS) — Node's original system: require() and module.exports, loaded synchronously at the moment require is called.

New code should be ESM. Node decides the format per file:

  • .mjs is always ESM, .cjs always CommonJS.
  • .js follows the nearest package.json: "type": "module" means ESM; no field (or "commonjs") means CJS. Node 22 can also detect ESM syntax in an ambiguous .js file, but being explicit is better.

Import built-ins with the node: prefix — import { readFile } from "node:fs/promises" — so they can never be confused with an npm package of the same name.

ESM rules that trip people up:

  • Relative imports need the file extension: import { add } from "./math.js".
  • require, __dirname and __filename don't exist. Use import.meta.dirname and import.meta.filename (Node 20.11+), or import.meta.url.
  • JSON needs an import attribute: import data from "./data.json" with { type: "json" }.

Interop: ESM can import a CommonJS module (its module.exports becomes the default export). Going the other way, Node 22.12+ lets require() load a synchronous ES module (one without top-level await); otherwise use dynamic import(), which works in both systems and returns a promise.

Prefer named exports for most things — they make refactoring and tree-shaking easier and show up in editor autocomplete.

Example

// package.json: { "type": "module" }

// math.js
export const add = (a, b) => a + b;
export default function mean(xs) {
  return xs.reduce(add, 0) / xs.length;
}

// main.js
import mean, { add } from "./math.js";        // extension is required
import { readFile } from "node:fs/promises";  // built-in with node: prefix
import pkg from "./package.json" with { type: "json" };

console.log(add(2, 3), mean([1, 2, 3]));      // 5 2
console.log(import.meta.dirname);             // folder of this file
console.log(pkg.type);                        // module

const { default: os } = await import("node:os"); // dynamic import, top-level await
console.log(os.platform());

Remember

  • "type": "module" in package.json makes .js files ESM
  • Use node: for built-ins and include file extensions in relative imports
  • import.meta.dirname replaces __dirname in ESM
  • Dynamic import() works everywhere; require(esm) only for sync ESM

Watch out for

  • Writing import x from './util' without .js — ESM does not guess extensions.
  • Using __dirname in an ES module and getting a ReferenceError.

Check yourself

  1. A package.json has no "type" field. How does Node treat app.js containing require() calls?
  2. In an ES module, how do you get the directory of the current file?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: nodejs.org/docs/latest-v22.x/api/esm.html