Modules, ESM and CommonJS

import versus require, the package.json type field, conditional exports, and how the two module systems talk to each other.

Two module systems, one runtime

Node supports two module systems side by side. CommonJS (require) is the original: synchronous, resolved at run time, and still what most published packages ship. ES modules (import) are the language standard, loaded before execution in a static graph.

// greeting.cjs — CommonJS
const os = require("node:os");

function greet(who) {
  return "hello " + who;
}

module.exports = { greet, platform: os.platform() };
// greeting.js — ES module ("type": "module")
import os from "node:os";

export function greet(who) {
  return "hello " + who;
}

export const platform = os.platform();
AspectCommonJSES modules
LoadingSynchronous, at run timeAsynchronous, before execution
ExportsA copied snapshot of module.exportsLive bindings to the original
Top-level awaitNot allowedAllowed
Directory of the file__dirname, __filenameimport.meta.dirname (Node 20.11+)
Static analysis for bundlersWeak — the graph is only known at run timeStrong — the graph is declared

How Node decides which system a file is

The extension wins first, then the nearest package.json. A .cjs file is always CommonJS and a .mjs file is always ESM; plain .js follows the type field, which defaults to "commonjs".

{
  "name": "my-api",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "default": "./dist/index.mjs"
    },
    "./plugins/*": "./dist/plugins/*.js"
  }
}
  • "type": "module" makes every .js in the package an ES module; the change is package-wide, not per file.
  • Adding exports replaces main and seals the package: paths not listed become unreachable from outside, which is how a package hides its internals.
  • Order matters inside a condition object — the first key that matches wins, so a default key must come last.
  • Subpath patterns (./plugins/*) let you expose a family of files without listing each one.
⚠️
The most common dual-package bug is adding exports and forgetting a subpath that consumers already import. That surfaces as Cannot find module for code that worked yesterday, so treat exports as a public API change.

Interop and dynamic import

// ESM can import CommonJS: the default export is module.exports
import legacy from "./greeting.cjs";
const name = legacy.greet("Ada");

// CommonJS cannot require() an ESM graph that awaits at the top level,
// so reach for the asynchronous form instead
async function load() {
  const { greet } = await import("./greeting.js");
  return greet("Ada");
}
  • An API named after a keyword is easier to detect; the reason module.exports and exports.x = ... differ is that the latter only works before the object is replaced.
  • From CommonJS you can require() an ESM graph as of Node 22 provided nothing in it awaits at the top level; otherwise the load fails and dynamic import() is the answer.
  • import() returns the module namespace object, and it is also the tool for lazy or optional dependencies inside a CommonJS file.
  • In ESM there is no __dirname and no require keyword. Derive paths from import.meta.dirname or import.meta.url.
  • Do not mix import and require syntax in one file — pick the system for the file and stay consistent.

FAQ

Why do I get 'require is not defined' after adding "type": "module"?
The setting switched every .js file in the package to ESM, so the CommonJS globals are gone. Either rename those files to .cjs, or convert them to import and replace __dirname with import.meta.dirname.
Should a new library ship both CommonJS and ESM?
Only if consumers still need CommonJS. Dual publishing doubles the test matrix and creates the dual-package hazard; many teams now ship ESM only and let callers on Node 22+ require it directly.

Async patterns, timers and the event loop Errors, logging and debugging

Last refreshed 2026-09-18.