Most Node.js projects still ship CommonJS. That is not a controversial claim; it reflects the inertia of millions of packages built before ES Modules were stabilized in Node 12.22, 14.17 and 15.3. But the ecosystem surrounding those projects — npm packages dropping CJS support, TypeScript defaulting to module settings that emit ESM, bundlers optimizing static import graphs — is accelerating toward ESM-only output. For intermediate Node.js developers maintaining server-side or full-stack production codebases, the question is no longer whether ESM will dominate but when migrating makes sense and how to do it without fracturing a dependency graph or stalling a CI pipeline.
This guide covers the technical differences between CommonJS and ESM in Node.js, the resolution rules that determine how each file is parsed, a checklist for deciding when to migrate, and a staged conversion strategy with concrete error-to-fix troubleshooting references.
Prerequisites: This guide assumes a currently supported Node.js release (Node 22 or Node 24 LTS recommended; Node 18 and 20 have reached end of life). Some features noted below require newer versions, which are called out inline. Examples use bash/zsh shell syntax unless otherwise noted.
Table of Contents
- How Node.js Determines Module Format
- Syntax and Behavioral Differences: CommonJS vs ES Modules
- The
exportsandimportsMaps inpackage.json - Decision Table: Should You Migrate?
- Step-by-Step Migration Guide
- Interop Patterns: Using CJS from ESM and Vice Versa
- Error-to-Fix Troubleshooting Table
- CommonJS vs ESM with TypeScript
- Make the Switch Deliberately
How Node.js Determines Module Format
Node.js does not guess a file's module format. It checks file extensions, the nearest package.json configuration, and, in narrow cases, a CLI flag.
File Extensions: .mjs, .cjs, and .js
Files ending in .mjs are always parsed as ES Modules, and files ending in .cjs are always parsed as CommonJS. Files ending in .js are ambiguous on their own; Node.js resolves them by consulting the nearest package.json up the directory tree.
project/
├── lib/
│ ├── utils.mjs # Always ESM
│ ├── config.cjs # Always CommonJS
│ └── index.js # Determined by nearest package.json "type" field
└── package.json
This means that .mjs and .cjs serve as escape hatches. A single CommonJS configuration file can coexist inside an otherwise ESM project simply by using the .cjs extension, and vice versa.
The type Field in package.json
The "type" field in package.json controls how .js files within its scope are parsed. Setting "type": "module" causes all .js files under that package.json to be treated as ESM. Setting "type": "commonjs", or omitting the field entirely, causes them to be treated as CommonJS.
ESM-scoped package.json:
{
"name": "my-esm-app",
"type": "module",
"version": "1.0.0"
}
Any .js file within this package's directory tree will be parsed as an ES Module. import and export syntax is expected; require() is not available.
CommonJS-scoped package.json:
{
"name": "my-cjs-app",
"type": "commonjs",
"version": "1.0.0"
}
Here, .js files are parsed as CommonJS. require(), module.exports, __dirname, and __filename all work as expected.
The scope boundary is the nearest package.json up the directory tree. In monorepos, this means each workspace with its own package.json controls its own module format independently; the root "type" field does not cascade into workspaces that define their own package.json.
The --input-type Flag for Non-File Input
The --input-type flag applies only to non-file input: --eval, --print, and STDIN. It does not affect files on disk.
node --input-type=module --eval "
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
const target = fileURLToPath(new URL('package.json', import.meta.url));
console.log(await readFile(target, 'utf8'));
"
Note: The
--evalsnippet above resolvespackage.jsonrelative toimport.meta.urlrather than the working directory. This avoids reading an unintended file if the script is invoked from a different CWD. On Windows PowerShell, use a single-line command or a script file instead of multi-line shell syntax.
Without --input-type=module, the import statement in --eval would trigger a syntax error because Node.js defaults to CommonJS parsing for evaluated strings.
Syntax and Behavioral Differences: CommonJS vs ES Modules
Exports and Imports
CommonJS uses require() for loading and module.exports or exports for exposing values. ESM uses import declarations and export statements. The Node.js modules documentation covers the syntax in detail, but the behavioral implications matter most during migration.
CommonJS:
// utils.cjs
const { join } = require('node:path');
function resolvePath(dir, file) {
if (typeof dir !== 'string' || typeof file !== 'string') {
throw new TypeError(`resolvePath expects strings, got: ${typeof dir}, ${typeof file}`);
}
return join(dir, file);
}
module.exports = { resolvePath };
ESM equivalent:
// utils.js (with "type": "module")
import { join } from 'node:path';
export function resolvePath(dir, file) {
if (typeof dir !== 'string' || typeof file !== 'string') {
throw new TypeError(`resolvePath expects strings, got: ${typeof dir}, ${typeof file}`);
}
return join(dir, file);
}
Named exports, default exports, and re-exports (export { foo } from './bar.js') all have direct ESM syntax. CommonJS achieves named exports through object properties on module.exports, which is less amenable to static analysis.
Top-Level await, __filename, __dirname, and this
ESM supports top-level await (Node 14.8+ unflagged), allowing modules to perform asynchronous operations at the top scope during evaluation. CommonJS does not support this; asynchronous work at the top level requires callbacks or wrapping in an immediately invoked async function.
The __filename and __dirname globals are not available in ES Modules. The standard replacement uses import.meta.url combined with Node.js built-in utilities (import.meta.url is a file:// URL; fileURLToPath converts it to an OS path string):
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
// import.meta.dirname is native in Node 21.2+ / Node 22 LTS
const __filename = import.meta.filename ?? fileURLToPath(import.meta.url);
const __dirname = import.meta.dirname ?? dirname(__filename);
import.meta.dirname and import.meta.filename are available natively in Node 21.2+ and Node 20.11+, and the Node docs mark them stable from 22.16 and 24.0. The snippet above uses ?? to fall back to the fileURLToPath pattern on older releases, so it works across all supported versions.
At module scope, this is undefined in ESM, whereas in CommonJS it refers to module.exports.
Loading Semantics: Synchronous vs Static Resolution
require() is synchronous. It reads the file, evaluates it, and returns the cached module.exports object. Once loaded, subsequent require() calls return the cached reference. This synchronous nature enables conditional loading patterns like const db = process.env.DB === 'pg' ? require('pg') : require('mysql2').
Node.js statically analyzes and resolves ESM
importdeclarations at link time, before any module code executes; unlikerequire(), they cannot be deferred to runtime.
Node.js resolves the module graph at link time, not at runtime. ESM named exports provide live bindings: if the exporting module updates a named export's value, importing modules see the change. This does not apply to values captured via default export. CommonJS exports are value copies at the time of require().
The practical implication is that conditional loading in ESM requires dynamic import(), which returns a promise: const { default: pg } = await import('pg').
The exports and imports Maps in package.json
Conditional Exports for Dual-Format Packages
The "exports" field in package.json lets package authors define entry points with conditions. This lets dual-format packages serve ESM to import consumers and CommonJS to require consumers.
{
"name": "my-library",
"version": "2.0.0",
"type": "module",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./utils": {
"import": "./dist/utils.js",
"require": "./dist/utils.cjs"
}
}
}
The "types" condition should appear before "default" so that TypeScript resolves type declarations correctly. This ordering is required for TypeScript 4.7+ with moduleResolution: nodenext; earlier versions do not read exports-mapped types at all. If you need to support TypeScript versions before 4.7, add a top-level "types" field in package.json alongside the "exports" map as a fallback. Subpath exports like "./utils" control the public API surface, preventing consumers from importing internal files not explicitly listed.
Self-Referencing with imports
The "imports" field defines private import aliases scoped to the package itself. Entries must start with # to distinguish them from bare specifiers. For packages that need to support both ESM and CJS consumers (dual-format), use conditional fields:
{
"imports": {
"#utils": {
"import": "./src/utils.js",
"require": "./src/utils.cjs",
"default": "./src/utils.js"
},
"#db": {
"import": "./src/db/client.js",
"require": "./src/db/client.cjs",
"default": "./src/db/client.js"
}
}
}
Inside the package, these aliases work as bare specifiers:
import { connect } from '#db';
import { formatDate } from '#utils';
This avoids brittle relative paths like ../../../src/utils.js and centralizes path mappings in one place. If your package is ESM-only (not dual-format), simple string values without conditional fields are sufficient.
Decision Table: Should You Migrate?
| Criterion | Stay on CommonJS | Migrate to ESM | Notes |
|---|---|---|---|
| Project type | Internal app with no public API | Library published to npm | Libraries should ship ESM or dual-format to avoid forcing consumers into interop workarounds |
| Node.js version floor | Node < 16 required | Node 16+ (LTS) or later | Node 12.22 and 14.17 stabilized the ESM implementation, but top-level await requires Node 14.8+, and practical tooling support solidified around Node 16. Node 12 and 14 are end-of-life. |
| Dependency ESM readiness | Key dependencies lack ESM | Major dependencies ship ESM | Check each dependency's package.json directly or use publint |
| TypeScript config | "module": "commonjs", no plans to change | "module": "nodenext" or "node16" | Requires both "module" and "moduleResolution" set to "nodenext" (TypeScript 4.7+) |
Top-level await need | Not needed | Required for startup logic | Only available in ESM (Node 14.8+ unflagged) |
| Bundler involvement | Output is server-only, unbundled | Bundler already in pipeline (Vite, Rollup, esbuild) | Bundlers handle ESM natively and tree-shake more effectively |
| Team familiarity | Team has not shipped an ESM package or debugged ERR_MODULE_NOT_FOUND in production | Team comfortable with import/export and full specifiers | Migration without understanding the resolution model generates hard-to-debug errors |
| Maintenance horizon | End-of-life or frozen codebase | Actively maintained for 2+ years | ESM-only dependencies will increasingly force the issue |
If you publish to npm, ship ESM now. Applications can migrate incrementally, module by module. Legacy projects pinned to Node versions below 16 should remain on CommonJS until they can upgrade their runtime floor.
Step-by-Step Migration Guide
Step 1: Audit Dependencies and Entry Points
Before changing any configuration, catalog every require() call, entry point, and script in the project. Automated tools such as cjs-to-esm or @babel/plugin-transform-modules-commonjs (in reverse) can flag conversion candidates — verify each tool's maintenance status and test on a branch before applying to production. Manually review test runners, build scripts, and tooling configuration files, many of which (Jest configs, Babel configs, Webpack configs) rely on CommonJS by default.
Step 2: Set "type": "module" and Rename Holdouts
This step will break any file that still uses require() or module.exports. Have your test suite ready before proceeding.
Add "type": "module" to the root package.json. Then rename any files that must remain CommonJS to .cjs. Common holdouts include tool configuration files that do not yet support ESM.
// package.json
{
"name": "my-app",
+ "type": "module",
"version": "3.1.0"
}
Only rename config files that actually use CJS syntax. Verify which config files exist first (e.g., ls .eslintrc*), since your project may use a different format (.json, flat config) or may not have these files at all.
mv .eslintrc.js .eslintrc.cjs
mv jest.config.js jest.config.cjs
Note: Jest does not auto-discover
.cjsconfig files in all versions. After renaming, either pass the config path explicitly (npx jest --config jest.config.cjs) or add"jest": {"config": "jest.config.cjs"}to yourpackage.jsonto ensure Jest uses the correct configuration.
Step 3: Convert require / module.exports to import / export
Replace require() calls with import declarations. Replace module.exports with export statements. Convert dynamic require() to await import().
Before (CommonJS):
const express = require('express');
const { readFile } = require('node:fs/promises');
const router = require('./routes/api');
const app = express();
app.use('/api', router);
module.exports = app;
After (ESM):
import express from 'express';
import router from './routes/api/index.js'; // ESM does not resolve index.js automatically; adjust path if api.js exists as a flat file
const app = express();
app.use('/api', router);
export default app;
Note the .js extension on the relative import. ESM requires full specifiers for relative paths; bare specifier resolution only applies to packages listed in node_modules. If the original require('./routes/api') resolved to ./routes/api/index.js, the ESM import must be './routes/api/index.js' — ESM does not perform automatic directory index resolution. The readFile import from the CommonJS version is not carried over because it is not used in this snippet; only import what you use.
Step 4: Update package.json exports for Libraries
If the project is a published library, add a conditional "exports" map as shown in the earlier section. This allows consumers using either require() or import to resolve the correct format.
Step 5: Update Tooling Configuration
What does your test runner expect? This step varies by tool, so check each one before making bulk changes.
TypeScript: Set "module" and "moduleResolution" to "nodenext" in tsconfig.json:
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2022",
"outDir": "./dist",
"declaration": true
}
}
Jest: For Jest with ESM, configure transform: {} in jest.config.cjs to disable default Babel transforms. Scope the experimental flag to the Jest invocation rather than setting it globally in NODE_OPTIONS (global settings propagate to all child Node processes, including postinstall scripts):
{
"scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules npx jest --config jest.config.cjs"
}
}
See Jest ESM docs for the full setup. Alternatively, consider switching to Vitest or Node's built-in test runner (node --test), both of which support ESM natively.
ESLint: The flat config format (eslint.config.js) is ESM-compatible by default (ESLint 8.21+). If using the legacy .eslintrc format, rename it to .eslintrc.cjs.
Step 6: Validate and Ship
On Node 22.7+ and 20.19+, syntax detection is on by default: a .js file with no "type" field that contains import or export is treated as ESM. That can hide a missing "type": "module", so set the field explicitly. To confirm that no file relies on detection, run the test suite with --no-experimental-detect-module. Check the flag exists on your version:
node --help 2>&1 | grep -F 'detect-module' && echo "flag available" || echo "flag not available on this version"
Run the full test suite, build pipeline, and any integration tests. A CI smoke test checklist should include: confirming all entry points resolve, verifying dynamic imports load correctly, and ensuring no ERR_REQUIRE_ESM or ERR_MODULE_NOT_FOUND errors appear in logs.
Interop Patterns: Using CJS from ESM and Vice Versa
Importing CommonJS Modules in ESM
CommonJS modules can be imported from ESM files using default import syntax. Named imports work only when Node.js can statically detect the CJS module's export keys at parse time — specifically, when the module assigns a static object literal to module.exports. Dynamic assignment (e.g., building the exports object conditionally at runtime) forces default-import-only.
import _ from 'lodash';
const { groupBy, sortBy } = _;
The destructuring happens at runtime rather than through static named imports because lodash's CommonJS exports are assigned dynamically to module.exports. Note that this pattern imports the entire lodash bundle at runtime, which defeats tree-shaking. For ESM-native tree-shaking, use import { groupBy } from 'lodash-es' if your project can take the lodash-es dependency.
Requiring ES Modules in CommonJS
Historically, require() could not load ES Modules at all. Current Node releases can: require(esm) is enabled without a flag from Node 22.12 and 20.19, and Node 22.13 and 20.19 stopped printing the experimental warning (see the Node.js modules docs). The call returns the module namespace object, so a default export is under .default. It throws ERR_REQUIRE_ASYNC_MODULE if the ESM graph uses top-level await; use import() in that case.
For older versions, the fallback is dynamic import() inside an async context:
async function loadConfig(timeoutMs = 5000) {
const timer = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Config load timed out')), timeoutMs)
);
const loader = import('./config.js').then((m) => m.default);
const config = await Promise.race([loader, timer]);
if (config === null || config === undefined) {
throw new Error('Config module exported nullish default');
}
return config;
}
This works on all Node.js versions with ESM support but requires callers to handle the returned promise. The timeout prevents the process from hanging indefinitely if the config module fails to load, and the explicit error handling avoids unhandled promise rejections (which crash the process in Node 15+).
Error-to-Fix Troubleshooting Table
| Error Message | Cause | Fix |
|---|---|---|
ERR_REQUIRE_ESM | CJS file require()-ing an ESM-only package on a Node release without require(esm) | On Node 22.12+ and 20.19+ require() loads most ESM, and this error now mostly appears on older runtimes or when the package uses top-level await (ERR_REQUIRE_ASYNC_MODULE). Switch to dynamic import() or upgrade Node |
SyntaxError: Cannot use import statement outside a module | .js file parsed as CJS but contains import syntax | Add "type": "module" to package.json or rename the file to .mjs |
ReferenceError: __dirname is not defined | ESM does not provide __dirname | Use import.meta.dirname (Node 20.11+/21.2+) or dirname(fileURLToPath(import.meta.url)) |
ERR_UNKNOWN_FILE_EXTENSION | Missing "type" field or custom loader misconfiguration | Set "type" in the nearest package.json or use .mjs/.cjs extensions explicitly |
ERR_MODULE_NOT_FOUND (missing extension) | ESM requires full specifiers including file extensions | Add .js extension to all relative import paths |
ERR_PACKAGE_PATH_NOT_EXPORTED | Imported subpath not listed in the package's "exports" map | Import only entry points the package explicitly exports. If the subpath is essential, use patch-package to apply a local patch, or open an issue with the package maintainer. |
CommonJS vs ESM with TypeScript
TypeScript mirrors Node.js module resolution through its "module" and "moduleResolution" compiler options. Setting "module": "nodenext" (or "node16") causes TypeScript to emit ESM when the enclosing package.json has "type": "module" and CommonJS otherwise. Setting both explicitly keeps the config clear (newer TypeScript versions infer moduleResolution from module).
The .mts extension produces .mjs output, and .cts produces .cjs output, matching Node.js's extension-based format detection. This allows individual files to opt into a different format than the package default, exactly as .mjs and .cjs do at runtime.
For dual-format library publishing, the build pipeline typically produces two output directories or uses file extensions to differentiate formats. The package.json "exports" map then points each condition to the correct file, with "types" entries referencing .d.ts and .d.cts declarations respectively.
Make the Switch Deliberately
Migrate libraries first, since they impose format constraints on every downstream consumer. Applications can migrate incrementally when their tooling and dependencies are ready. Use the decision table above to justify timing rather than defaulting to either inertia or hype.
CommonJS is not deprecated and remains stable, but the signals point one direction: packages like
chalk,got, andexecahave dropped CJS entirely, and TypeScript'snodenextresolution treats ESM as the primary target. Waiting is fine. Waiting without a plan is not.
For the canonical reference on Node.js module resolution, package configuration, and interoperability rules, consult the Node.js Packages documentation.

