· Revised
.mjs, .cjs, and how Node decides
How Node chooses ESM or CommonJS from a file extension and package.json, and what require(esm) changed for packages that ship both.
Node has two module systems: CommonJS (require, module.exports) and ECMAScript modules (import / export). I first wrote this in 2023 because .mjs is the most direct way to opt a file into ESM. That is still true. What people actually trip over is the "type" field, "exports" conditions, and require() of ESM, and parts of that have changed.
How Node chooses
The packages documentation is the source of truth. For a file Node is asked to run or import:
.mjsis always an ES module, regardless of the nearestpackage.json..cjsis always CommonJS, for the same reason..jsfollows the nearest parentpackage.json."type": "module"makes it ESM;"type": "commonjs", or no"type"field at all, makes it CommonJS.
.mjs therefore needs no package configuration. This is the example I used in 2023, and it still runs with node main.mjs:
// mathUtils.mjs
export function add(a, b) {
return a + b
}
export function multiply(a, b) {
return a * b
}
// main.mjs
import { add, multiply } from './mathUtils.mjs'
console.log(`Sum: ${add(5, 3)}`)
console.log(`Product: ${multiply(4, 2)}`)
The extension is the signal. The same import in a .js file is a syntax error unless that file is already in an ESM scope. File extensions override "type", which is how you mix formats in one package: a "type": "module" package can still keep a CommonJS file by naming it .cjs, and a CommonJS package can keep an ESM file by naming it .mjs.
Package authors should set "type" explicitly, even when every file is CommonJS. Node may change the default later, and tools should not have to guess. Since Node 22.7 and 20.19, an ambiguous .js file (no "type" in scope) is also subject to syntax detection: if the source can only parse as ESM, Node retries it as ESM. That has a cost, and it is a fallback, not a reason to omit "type".
import and require also resolve specifiers differently. A relative or absolute import must include the file extension, and directory indexes have to be written out (./startup/index.js, not ./startup). require('./startup') will still search .js, .json, and .node, and will still treat a folder as a module.
What .mjs is not
ESM can be statically analysed. Bundlers use that to drop unused exports, which is tree shaking. If main.mjs only imports add, a bundler can omit multiply from the output. That is a property of import / export, not of the .mjs extension, and it is not something Node does when you run node main.mjs. I overstated this in 2023.
Dynamic import() is not an .mjs feature either. It is available in both CommonJS and ESM, and it is the way a CommonJS file loads an ES module when it needs to do so asynchronously:
const { add } = await import('./mathUtils.mjs')
In a browser, the decision is the script's type="module" (and the JavaScript MIME type the server sends), not the file extension. .mjs is not special there. Bare specifiers such as import 'lodash' also do not resolve in a browser without an import map, and Node built-ins do not exist there. ESM is a shared syntax, not a promise that the same file runs unchanged on both sides without a bundler.
An ES module has to name its dependencies in import statements, and top-level bindings stay in the module. They are not written onto a shared exports object, and they do not leak into other files. That is the practical difference from CommonJS, and it is why I preferred ESM in 2023.
Packages: "exports" and the dual-package hazard
For a package, "main" is only the default entry. "exports" replaces it when present: it can list subpaths, and it hides anything not listed. Consumers of pkg/hidden.js get ERR_PACKAGE_PATH_NOT_EXPORTED.
Conditional exports then pick a file based on how the package is loaded. "import" and "require" are mutually exclusive. Key order matters; more specific conditions go first, and "default" last:
{
"type": "module",
"exports": {
"import": "./index.mjs",
"require": "./index.cjs"
}
}
That pattern is common, and it has a cost. If one part of an application imports the package and a dependency requires it, Node can evaluate two different files and keep two copies in memory. Constructors then fail instanceof across the boundary, and mutations of a singleton on one copy are invisible to the other. Node's name for this is the dual package hazard; the current docs point at the package examples repository for the worked cases. The usual mitigations are to wrap a single CommonJS implementation for import, or to isolate any state in one shared file that both entry points load.
require(esm)
Until recently, require() of a native ES module threw ERR_REQUIRE_ESM. That is why so many packages shipped a separate .cjs build. Loading ESM with require() was unflagged in Node 22.12 and 20.19, and in current Node it is stable (as of 25.4). The module must be synchronous. If the graph contains top-level await, require() throws ERR_REQUIRE_ASYNC_MODULE and you should use import() instead.
const { add } = require('./mathUtils.mjs')
const Point = require('./point.mjs').default
The return value is the module namespace, so a default export is on .default. A package that only needs to support these Node versions can ship ESM and let both import and require load the same file, which removes the split that causes the hazard:
{
"type": "module",
"exports": "./index.js",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
The "module-sync" exports condition matches that same synchronous-ESM case for both loaders.
.mjs remains the local way to force a format. "type" does the same at package scope. A split "import" / "require" pair is a compatibility choice with a real failure mode, and it should not be the default shape of a new package.