Using ES Modules and CommonJS Together in Node.js
Node.js manages the coexistence of ECMAScript Modules (ESM) and CommonJS (CJS) through distinct file extensions, package-level configurations, and specific loading mechanisms. While both systems can operate within the same application, strict rules govern how they reference and execute one another. Understanding file boundary definitions, inter-module import techniques, and conditional package exports is essential for building a hybrid or migrating codebase without runtime failures.
Determining Module Formats
Node.js determines whether a JavaScript file should be parsed as ESM
or CommonJS based on two primary factors: file extensions and the
nearest package.json configuration.
- File Extensions: Files ending in
.mjsare always treated as ES Modules, whereas files ending in.cjsare always treated as CommonJS, regardless of package configuration. - The
typeField: In standard.jsfiles, Node.js references the"type"field in the closestpackage.json. If"type": "module"is declared,.jsfiles are treated as ESM. If"type": "commonjs"is set or the field is omitted entirely,.jsfiles default to CommonJS.
Loading CommonJS from ES Modules
ES Modules can load CommonJS modules directly using the standard
static import syntax. However, Node.js treats the entire
module.exports object of the CommonJS file as the default
export.
// Importing CommonJS into an ES Module
import cjsModule from './legacy-module.cjs';Named imports from CommonJS modules (e.g.,
import { feature } from './legacy-module.cjs') are
supported via static analysis heuristics, but this can be unreliable for
dynamically assigned properties. To avoid runtime errors, import the
default export and destructure the required properties afterward.
Loading ES Modules from CommonJS
CommonJS cannot load ES Modules using the synchronous
require() function because ES Modules are inherently
asynchronous in their initialization. Attempting to run
require('./module.mjs') throws an
ERR_REQUIRE_ESM error.
To load an ES Module within a CommonJS environment, you must use
dynamic import() expressions, which return a Promise
resolving to the module namespace:
// Importing an ES Module into CommonJS
async function loadESM() {
const esmModule = await import('./modern-module.mjs');
esmModule.doSomething();
}Global Variable Discrepancies
When mixing module formats, global scope differences require alternative implementations:
- CommonJS globals like
__dirname,__filename,require, andmoduledo not exist in ESM. - To replicate
__dirnameand__filenamein ESM, useimport.meta.url:
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);Conditional Exports for Dual Packages
For libraries that must support both formats simultaneously, the
package.json "exports" field enables
conditional entry points. Node.js automatically routes consumers to the
appropriate build based on their environment:
{
"name": "my-library",
"exports": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}This mapping allows ESM consumers to use import while
CJS consumers use require(), preventing module resolution
conflicts across diverse tooling setups.