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.

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:

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.