CommonJS vs ES Modules

intermediate modules commonjs esm import require

Node has two module systems. CommonJS (CJS) is the original — require() and module.exports. ES Modules (ESM) is the standard from the JS spec — import and export. Knowing how they differ matters because mixing them up causes very real production bugs.

The two systems at a glance

CommonJS (CJS)
Default extension: .js (or .cjs)
Synchronous loading
require() / module.exports
__dirname, __filename available
No top-level await
Loaded by reading + wrapping in a function
ES Modules (ESM)
Default extension: .mjs (or .js with "type":"module")
Asynchronous loading
import / export
import.meta.url instead of __dirname
Top-level await works
Static graph — imports must be at top

How Node decides which system a file is

The rules in order:

  1. File ends in .cjs → CommonJS.
  2. File ends in .mjs → ESM.
  3. File ends in .js → look at the nearest package.json:
    • "type": "module" → ESM
    • "type": "commonjs" or no type field → CommonJS
// package.json
{
  "type": "module"
}

With that, every .js file in the package is treated as ESM. If we still need a CJS file inside, we use .cjs.

Syntax side by side

// CommonJS
const fs = require("node:fs");
const { readFile } = require("node:fs/promises");

function greet(name) {
  return `Hello, ${name}`;
}

module.exports = { greet };
// or: module.exports.greet = greet;
// ES Modules
import fs from "node:fs";
import { readFile } from "node:fs/promises";

export function greet(name) {
  return `Hello, ${name}`;
}

// or default export:
// export default greet;

The gotchas

1. __dirname doesn’t exist in ESM

In CJS, __dirname and __filename are free variables. In ESM, they’re gone. We use import.meta.url:

import { fileURLToPath } from "node:url";
import { dirname } from "node:path";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

Node 20.11+ added import.meta.dirname and import.meta.filename so we can skip the boilerplate.

2. ESM imports MUST include the extension

CJS lets us write require("./utils") and it tries .js, .json, .node. ESM is strict — we have to write ./utils.js. (Node 22+ has a --experimental-default-type flag and there’s ongoing work to relax this for node_modules.)

3. You can require() ESM (sometimes)

Until recently, require() of an ESM file threw ERR_REQUIRE_ESM. Node 22+ supports require() of synchronous ESM (no top-level await) under a flag, and Node 23+ enables it by default. Older versions force us to use dynamic import():

// In a CJS file, loading an ESM module:
async function load() {
  const mod = await import("./esm-module.mjs");
  mod.doStuff();
}

4. Named exports from CJS into ESM

Importing a CJS module from ESM gives us the whole module.exports as the default. Node tries to detect named exports too, but if it can’t (e.g., they’re set dynamically), we have to destructure manually:

// CJS package
import pkg from "lodash";
const { debounce } = pkg;
// or, if Node detects named exports:
import { debounce } from "lodash";

5. JSON imports need an attribute

import data from "./data.json" with { type: "json" };

Dual packages — supporting both

Library authors often ship both. The package.json exports field is how we tell Node which file to use:

{
  "name": "my-lib",
  "type": "module",
  "main": "./dist/index.cjs",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

This is called conditional exports. The import key wins for ESM consumers, require wins for CJS.

When to use which

For new code in 2026, prefer ESM. It’s the standard, bundlers prefer it, top-level await is great, tree-shaking works better. The only reason to stay on CJS is a large existing codebase or a hot-loaded plugin system that needs sync require.