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();| Aspect | CommonJS | ES modules |
|---|---|---|
| Loading | Synchronous, at run time | Asynchronous, before execution |
| Exports | A copied snapshot of module.exports | Live bindings to the original |
Top-level await | Not allowed | Allowed |
| Directory of the file | __dirname, __filename | import.meta.dirname (Node 20.11+) |
| Static analysis for bundlers | Weak — the graph is only known at run time | Strong — 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.jsin the package an ES module; the change is package-wide, not per file.- Adding
exportsreplacesmainand 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
defaultkey 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.exportsandexports.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 dynamicimport()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
__dirnameand norequirekeyword. Derive paths fromimport.meta.dirnameorimport.meta.url. - Do not mix
importandrequiresyntax 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.
Related
Async patterns, timers and the event loop Errors, logging and debugging
Last refreshed 2026-09-18.