Navigating ES Modules in Node.js - A Guide to Modern JavaScript
James Reed
Infrastructure Engineer · Leapcell

Introduction: The Dawn of Modern JavaScript Modules in Node.js
For a long time, JavaScript as a language lacked a native module system. This void was filled by various solutions, most notably CommonJS in the Node.js environment and AMD/RequireJS in the browser. While CommonJS served Node.js exceptionally well for years, the advent of ES Modules (ESM) as the official, standardized module system for JavaScript brought a new paradigm. With ESM, JavaScript aimed for a unified module system that works seamlessly across both client-side and server-side environments.
The integration of ESM into Node.js, however, wasn't a straightforward replacement. Node.js has a massive ecosystem built upon CommonJS, and ensuring backward compatibility while embracing the future required careful consideration. This transition has led to a dual-module system coexisting in Node.js, presenting developers with choices and sometimes challenges. Understanding the fundamental differences between CommonJS and ESM, along with strategies for migrating existing projects, is crucial for any Node.js developer looking to write modern, future-proof JavaScript applications. This article will delve into these distinctions, provide practical examples, and guide you through the process of adopting ESM in your Node.js projects.
Understanding JavaScript Modules: CommonJS vs. ES Modules
Before we deep dive into the nuances, let's establish a clear understanding of the core concepts and mechanisms behind both CommonJS and ES Modules.
Core Terminology
- Module System: A mechanism for organizing code into separate, reusable files (modules) and defining how these modules interact (importing and exporting functionalities).
- CommonJS (CJS): The default module system in Node.js for many years. It uses
require()
for importing modules andmodule.exports
orexports
for exporting functionalities. - ES Modules (ESM): The official, standardized module system for JavaScript, specified by ECMAScript. It uses
import
andexport
statements. - Package.json
type
field: A field inpackage.json
that dictates how Node.js should interpret files within a package. Setting it to"module"
treats.js
files as ESM, while"commonjs"
(or its absence) treats them as CommonJS. - File Extensions: Node.js uses file extensions to infer the module system.
.mjs
files are treated as ESM, and.cjs
files are treated as CommonJS, regardless of thepackage.json
type
field. - Dual Package Hazard: A situation where a package provides both CommonJS and ESM versions, potentially leading to inconsistencies or errors if both are loaded in the same application context with different module systems.
CommonJS: The Traditional Node.js Approach
CommonJS operates synchronously, meaning that when you require()
a module, Node.js blocks execution until that module is loaded and evaluated. This synchronous nature is generally suitable for server-side environments where file I/O isn't as critical as in a browser.
Exporting in CommonJS:
You export functionalities using module.exports
or exports
.
// math.js (CommonJS) function add(a, b) { return a + b; } const subtract = (a, b) => a - b; module.exports = { add, subtract }; // Or directly assign to exports // exports.add = add; // exports.subtract = subtract;
Importing in CommonJS:
You import modules using the require()
function.
// app.js (CommonJS) const math = require('./math'); console.log(math.add(5, 3)); // Output: 8 console.log(math.subtract(10, 4)); // Output: 6
ES Modules: The Modern Standard
ES Modules are designed to be asynchronous, allowing for better optimization and non-blocking loading, which is crucial for web browsers. In Node.js, while loaded synchronously from the file system, their internal resolution and evaluation follow an asynchronous graph traversal. ESM features static analysis, meaning imports/exports are determined at parse time, allowing for features like tree-shaking and better tooling support.
Exporting in ESM:
You export functionalities using the export
keyword.
// math.mjs (ESM) or math.js with "type": "module" in package.json export function add(a, b) { return a + b; } export const subtract = (a, b) => a - b; // Default export // export default function multiply(a, b) { // return a * b; // }
Importing in ESM:
You import modules using the import
keyword.
// app.mjs (ESM) or app.js with "type": "module" in package.json import { add, subtract } from './math.mjs'; // Must include file extension console.log(add(5, 3)); // Output: 8 console.log(subtract(10, 4)); // Output: 6 // Importing a default export // import multiply from './math.mjs'; // console.log(multiply(2, 3)); // Output: 6 // Dynamic import (asynchronous) async function doCalculations() { const { add } = await import('./math.mjs'); console.log('Dynamic add:', add(10, 5)); } doCalculations();
Key Differences Summarized
Feature | CommonJS (CJS) | ES Modules (ESM) |
---|---|---|
Syntax | require() , module.exports , exports | import , export |
Loading | Synchronous | Asynchronous (static graph analysis) |
Binding | Live copy of exported values | Live binding (references, not copies) |
Top-level this | module.exports | undefined |
File extensions | .js (default), .cjs | .mjs , .js (with "type": "module" ) |
__dirname , __filename | Available | Not directly available, use import.meta.url for paths |
package.json type | Default (or "commonjs" ) | "module" |
Dynamic Imports | Not native, often requires bundling/babel | Native import() function (returns a Promise) |
Interoperability: Bridging the Gap
Node.js provides mechanisms to allow some level of interoperability between CJS and ESM.
1. ESM importing CJS:
An ESM module can import
a CommonJS module. The import
statement will treat the CommonJS module's module.exports
as its default export. Named exports are not directly available unless the CommonJS module explicitly assigns them to module.exports
as properties.
// commonjs-lib.cjs module.exports = { foo: 'bar', baz: () => 'qux' }; // esm-app.mjs (or esm-app.js with "type": "module") import cjsLib from './commonjs-lib.cjs'; // default import console.log(cjsLib.foo); // Output: bar console.log(cjsLib.baz()); // Output: qux
2. CJS requiring ESM:
By default, CommonJS modules cannot directly require()
an ES Module. This is a significant limitation. The require()
function is synchronous and cannot handle the asynchronous nature of ESM resolution. If you attempt this, you will likely encounter an error like ERR_REQUIRE_ESM
.
To require
an ESM from a CJS module, you would typically need to use a dynamic import()
within the CJS context, which returns a Promise. This makes the require
effectively asynchronous.
// esm-lib.mjs export const greeting = 'Hello from ESM!'; // commonjs-app.cjs async function loadESM() { const esm = await import('./esm-lib.mjs'); console.log(esm.greeting); // Output: Hello from ESM! } loadESM();
This is generally discouraged for synchronous CJS dependencies and hints at needing to rewrite the CJS module to ESM if that's a frequent dependency.
Node.js Module Resolution Strategy
Node.js determines whether a file is CJS or ESM based on these rules, in order of precedence:
- File extensions:
.mjs
is always ESM..cjs
is always CJS. package.json
type
field: If apackage.json
file declares"type": "module"
, then.js
files within that package (and its subdirectories, unless overridden by anotherpackage.json
) are treated as ESM. If"type": "commonjs"
(or absent),.js
files are CJS.- Parent module type: If a file is imported by a module of a specific type (e.g., an
.mjs
file imports a.js
file), Node.js might infer the imported file's type if no other rules apply. However, relying on this is less explicit and can be confusing.
The most robust way to manage module types is using explicit file extensions (.mjs
, .cjs
) or the package.json
type
field.
Migration Strategies to ES Modules
Migrating a CJS codebase to ESM can be a gradual process. Here are common strategies to facilitate this transition.
1. Incrementing with .mjs
files
This is often the safest starting point for a large existing CJS project.
- Strategy: Start writing new modules as ESM by giving them the
.mjs
extension. Keep existing CJS modules as they are. - Pros: Minimal disruption to existing code. Allows you to gradually introduce ESM.
- Cons: You'll have to manage two module systems concurrently. ESM modules can
import
CJS modules, but CJS modules cannotrequire
.mjs
modules directly (without dynamicimport()
). - When to use: When you have a stable, large CJS codebase and want to introduce ESM for new features or refactors without a big-bang rewrite.
Example:
// package.json (no "type" field implies CommonJS for .js files)
{
"name": "my-app",
"version": "1.0.0"
}
// old-commonjs-util.js
module.exports = {
greeting: "Hello from old CJS!"
};
// new-esm-feature.mjs
import { greeting } from './old-commonjs-util.js'; // ESM importing CJS
console.log(greeting);
export function getFeatureData() {
return "New ESM data";
}
// app.js (CommonJS entry point)
const { getFeatureData } = require('./new-esm-feature.mjs'); // This will fail! CJS cannot directly require ESM.
// A dynamic import would be needed here:
async function run() {
const { getFeatureData: esmFeature } = await import('./new-esm-feature.mjs');
console.log(esmFeature());
}
run();
This example highlights the challenge: While ESM can import CJS, CJS cannot directly require
ESM, forcing a switch to asynchronous import()
or a full conversion.
2. Adopting "type": "module"
for a Package
This is a more comprehensive approach for packages or applications that want to fully embrace ESM.
- Strategy: Set
"type": "module"
in yourpackage.json
. This makes all.js
files within that package (and its subfolders) treated as ESM by default. - Pros: Cleans up the code, making
import/export
the default. Encourages full ESM adoption. - Cons: Requires either full conversion of all
.js
files to ESM syntax or explicitly marking CJS files with the.cjs
extension. - When to use: For new projects, or actively maintained projects that are ready for a full transition.
Example:
// package.json
{
"name": "my-esm-app",
"version": "1.0.0",
"type": "module" // All .js files are now ESM
}
// esm-math.js (treated as ESM)
export function add(a, b) {
return a + b;
}
// esm-app.js (treated as ESM)
import { add } from './esm-math.js';
console.log(add(10, 20));
// If you still have CJS dependencies, mark them with .cjs:
// legacy-cjs-module.cjs
module.exports = {
getConfig: () => ({ version: '1.0' })
};
// esm-app.js
import { add } from './esm-math.js';
import cjsConfig from './legacy-cjs-module.cjs'; // ESM importing CJS .cjs file
console.log(add(10, 20));
console.log(cjsConfig.getConfig());
3. Handling __dirname
and __filename
In ESM, __dirname
and __filename
are not directly available. You can construct similar paths using import.meta.url
.
// esm-module.mjs or esm-module.js with "type": "module" import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); console.log('Current file path:', __filename); console.log('Current directory path:', __dirname);
4. Migrating Third-Party Dependencies
This is often the most challenging part.
-
Identify ESM-compatible versions: Check if your dependencies have released ESM versions. Many popular libraries now provide both CJS and ESM builds.
-
Conditional exports (
exports
field inpackage.json
): Many libraries use theexports
field inpackage.json
to define conditional exports, allowing them to serve different module versions based on the importing environment (e.g.,"import"
for ESM,"require"
for CommonJS). This is how Node.js intelligently resolves which version of a package to load.// Example of a dependency's package.json { "name": "my-library", "main": "./dist/cjs/index.js", // CommonJS entry point "module": "./dist/esm/index.js", // ES Module entry point (older way) "exports": { ".": { "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js" } } }
Your application, if it's ESM, will automatically pick up
"import"
defined exports. -
Transpilation (using Babel/TypeScript): For packages that are not yet ESM-ready, you can continue to
import
them as if they were CJS (which they will be treated as). If you need to produce a pure ESM output from your project but rely on CJS dependencies, your build tool (like Rollup or Webpack with appropriate loaders) can often transpile/bundle CJS dependencies into your ESM output. -
Dynamic
import()
for CJS-only dependencies: If an ESM module needs to use a CJS-only dependency that doesn't offer an ESM equivalent, you can use dynamicimport()
to load it. However, this is asynchronous and primarily useful for situations where the dependency can be loaded on demand.// esm-app.mjs async function init() { const moment = await import('moment'); // 'moment' is generally CJS-only console.log(moment().format('LLL')); } init();
Conclusion: Embracing the Future of JavaScript Modules
The transition to ES Modules in Node.js signifies a major step towards a more unified and modern JavaScript ecosystem. While the coexistence of CommonJS and ESM introduces initial complexities, understanding their differences, Node.js's resolution mechanisms, and practical migration strategies empowers developers to build more robust and future-proof applications. By gradually adopting ESM, utilizing proper file extensions and package.json
configurations, and carefully managing interoperability, developers can smoothly navigate this evolution, ultimately benefiting from the improved ergonomics, static analysis capabilities, and standardization that ES Modules bring to Node.js development. The journey to a fully ESM-native Node.js is ongoing, but the path is clear and well-supported.