Node.js 5 🟢 ECMAScript Modules (import and export in Node.js)
ECMAScript Modules (ESM) is the official standard module system for JavaScript, introduced in ES2015. Where CommonJS was designed specifically for Node.js, ESM was designed for the language itself, intended to work in browsers, Node.js, Deno, and any other JavaScript runtime. It uses import and export statements instead of require() and module.exports, and its static structure enables optimizations like tree shaking that CommonJS cannot support.
Node.js supported CommonJS exclusively for its first decade. ESM support arrived experimentally in Node.js 8.5.0 and stabilized in Node.js 13.2.0, but the transition has been gradual because the npm ecosystem is built on CommonJS and the two systems are not fully interchangeable. Node.js 22 enabled require(esm) by default, and Node.js 25.4.0 marked it as stable, formally closing the compatibility gap . This means modern Node.js can load ESM from both import and require, though differences in loading semantics remain.
This chapter covers how Node.js determines whether a file is ESM or CommonJS, the import and export syntax, the differences between ESM and CommonJS, interoperability between the two systems, and the tools available for navigating mixed-module codebases.
Key point: Node.js decides a file’s module format by extension (.mjs = ESM, .cjs = CommonJS) and by the "type" field in the nearest package.json (.js = ESM if "type": "module", otherwise CommonJS). ESM uses static import/export syntax, supports top-level await, and enables tree shaking.
Why ESM exists
The standardization problem. CommonJS was never a language standard. It was a community convention adopted by Node.js before the ECMAScript committee defined a module system. Browsers could not use require() or module.exports without bundlers. ESM was designed as a single standard that works identically across every JavaScript environment, eliminating the bundler requirement for modern browsers.
The static analysis problem. CommonJS require() is a function call. It can appear anywhere in a file, inside conditionals, inside loops, inside functions. This makes it impossible for tools to determine a module’s dependencies without executing the code. ESM imports and exports are static declarations that must appear at the top level. Bundlers can analyze the module graph without running anything, enabling tree shaking — the removal of unused exports from the final bundle .
The asynchronous loading problem. CommonJS loads modules synchronously. require() blocks until the module is loaded and executed. This works for local files but is problematic for network-loaded modules. ESM was designed with asynchronous loading in mind. The module graph is resolved before execution begins, and dependencies can be fetched asynchronously .
The circular dependency clarity problem. CommonJS handles circular dependencies by returning partially complete exports. ESM handles them by hoisting imports and binding exports as live references. The ESM behavior is more predictable because the export bindings are established during instantiation, before any module code executes .
The browser unification problem. Before ESM, sharing code between Node.js and browsers required bundlers, transpilers, or UMD wrappers. ESM allows the same module to work in both environments without modification, provided the code does not use runtime-specific APIs. This unification reduces the tooling burden for isomorphic JavaScript.
a. How Node.js determines a file’s module format
Node.js uses file extensions and package.json to decide whether a .js file is ESM or CommonJS. The rules are explicit :
.mjsfiles are always ESM..cjsfiles are always CommonJS..jsfiles are ESM if the nearest parentpackage.jsonhas"type": "module"..jsfiles are CommonJS if the nearest parentpackage.jsonhas no"type"field, or"type": "commonjs", or there is nopackage.jsonat all.
The "type" field was introduced in Node.js 13.2.0 to allow .js files to be ESM without renaming them to .mjs . Without it, every ESM file in a Node.js project would need the .mjs extension, which is inconvenient and incompatible with many tools that expect .js.
{
"name": "my-project",
"type": "module"
}
With this package.json, every .js file in the project is ESM. If a specific file needs to remain CommonJS, rename it to .cjs. Node.js also supports syntax detection for ambiguous files: if a .js file without a package.json type fails to parse as CommonJS due to ESM syntax, Node.js retries it as ESM. This behavior was enabled by default in Node.js 20.19.0 .
b. Import syntax
ESM imports use the import keyword and must appear at the top level of the module. They are hoisted, meaning they are processed before any other code in the file .
// Named imports
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
// Default import
import express from 'express';
import fs from 'node:fs';
// Namespace import
import * as path from 'node:path';
// Side-effect only
import './setup.js';
// Dynamic import (works in both ESM and CJS)
const module = await import('./feature.js');
Node.js built-in modules can be imported with or without the node: prefix. The node: prefix is preferred because it makes the import unambiguous and prevents accidental shadowing by a userland package named fs or path .
A crucial difference from CommonJS: ESM requires the file extension in relative imports. import { add } from './utils.js' works; import { add } from './utils' does not . There is no extension resolution, no index.js fallback, and no directory imports. This aligns Node.js ESM with browser behavior, where extensions are always required.
c. Export syntax
Exports use the export keyword. A module can have multiple named exports and one default export .
// Named exports
export const version = '1.0.0';
export function greet(name) { return `Hello, ${name}`; }
export class User { constructor(name) { this.name = name; } }
// Default export
export default function log(message) { console.log(message); }
// Export list
const a = 1;
const b = 2;
export { a, b };
// Re-exporting
export { something } from './other.js';
export * from './utilities.js';
A module can have only one default export, but it can have any number of named exports alongside it. When importing a default export, the local name is chosen by the importer, not fixed by the exporter. Named exports must match the exported name exactly unless aliased with as .
d. Top-level await and import.meta
Two features distinguish ESM from CommonJS at the language level. Top-level await allows asynchronous operations at the module’s top level without wrapping them in an async function :
const config = await readFile('config.json', 'utf-8');
const settings = JSON.parse(config);
console.log(settings.port);
This works because ESM loading is asynchronous and the module graph can accommodate pending promises. If a top-level await never resolves, the Node.js process exits with status code 13 .
import.meta provides metadata about the current module. import.meta.url is the file URL of the module, import.meta.dirname is the directory path, and import.meta.filename is the file path . These replace the CommonJS __dirname and __filename, which do not exist in ESM.
console.log(import.meta.url); // file:///path/to/module.mjs
console.log(import.meta.dirname); // /path/to
console.log(import.meta.filename); // /path/to/module.mjs
To recreate __dirname in ESM:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
e. Interoperability: importing CommonJS from ESM
ESM can import CommonJS modules without special configuration. Node.js wraps the CommonJS exports in an ESM-compatible facade .
// CommonJS module: circle.cjs
exports.area = (r) => Math.PI * r ** 2;
// ESM module
import pkg from './circle.cjs';
console.log(pkg.area(4)); // 50.265...
// Named imports may work if Node.js can statically detect them
import { area } from './circle.cjs';
The default import always works and gives access to the entire module.exports object. Named imports work only when Node.js can statically analyze the CommonJS exports using cjs-module-lexer. If the CommonJS module assigns exports dynamically — for example, in a loop or after an if statement — named imports will fail and only the default import is available.
f. Interoperability: requiring ESM from CommonJS
Historically, CommonJS could not require() an ESM module. The ERR_REQUIRE_ESM error was a common frustration. Node.js 22 enabled require(esm) by default, and Node.js 20.19.0 backported the feature .
// ESM module: math.mjs
export function add(a, b) { return a + b; }
// CommonJS module
const { add } = require('./math.mjs');
console.log(add(2, 3)); // 5
When require() loads an ESM module, it returns the module namespace object, which behaves similarly to module.exports for property access. The limitation is top-level await: if the ESM module or any of its transitive dependencies contains top-level await, require() throws ERR_REQUIRE_ASYNC_MODULE. Synchronous ESM modules are requireable; asynchronous ones are not .
Complete Example Session
// ============================================
// PART 1: ENABLING ESM VIA package.json
// ============================================
// Setting "type": "module" makes all .js files ESM.
// package.json:
{
"name": "esm-demo",
"version": "1.0.0",
"type": "module"
}
// ============================================
// PART 2: NAMED EXPORTS
// ============================================
// math.js — multiple named exports.
// math.js:
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export class Calculator {
multiply(a, b) { return a * b; }
}
// ============================================
// PART 3: NAMED IMPORTS
// ============================================
// app.js — importing named exports.
// app.js:
import { PI, add, Calculator } from './math.js';
console.log(PI); // 3.14159
console.log(add(2, 3)); // 5
const calc = new Calculator();
console.log(calc.multiply(4, 5)); // 20
// ============================================
// PART 4: DEFAULT EXPORT
// ============================================
// logger.js — single default export.
// logger.js:
export default function log(message) {
console.log(`[LOG] ${message}`);
}
// ============================================
// PART 5: DEFAULT IMPORT
// ============================================
// The importer chooses the local name.
// app.js:
import log from './logger.js';
log('Application started');
// ============================================
// PART 6: TOP-LEVEL AWAIT
// ============================================
// No async wrapper needed in ESM.
// config.mjs:
import { readFile } from 'node:fs/promises';
const data = await readFile('config.json', 'utf-8');
const config = JSON.parse(data);
console.log(config.port);
// ============================================
// PART 7: import.meta
// ============================================
// Replacing __dirname and __filename.
console.log(import.meta.url); // file:///path/to/file.mjs
console.log(import.meta.dirname); // /path/to
console.log(import.meta.filename); // /path/to/file.mjs
// ============================================
// PART 8: IMPORTING COMMONJS FROM ESM
// ============================================
// ESM can import CJS; default import always works.
// legacy.cjs:
module.exports = { name: 'legacy', version: '1.0.0' };
// app.mjs:
import legacy from './legacy.cjs';
console.log(legacy.name); // 'legacy'
// ============================================
// PART 9: REQUIRING ESM FROM COMMONJS
// ============================================
// Node.js 22+ supports require(esm) by default.
// utils.mjs:
export function greet(name) {
return `Hello, ${name}`;
}
// app.cjs:
const { greet } = require('./utils.mjs');
console.log(greet('World')); // 'Hello, World'
// ============================================
// PART 10: DYNAMIC IMPORT
// ============================================
// Works in both ESM and CommonJS.
// Conditional loading:
if (process.env.NODE_ENV === 'development') {
const { debug } = await import('./debug.js');
debug.enable();
}
These ten parts cover ESM configuration, named and default exports, top-level await, import.meta, and interoperability in both directions. The final example shows dynamic import as the universal bridge between module systems.
Quick Reference
Module Format Detection
| Extension | "type": "module" | Format |
|---|---|---|
.mjs | Any | ESM |
.cjs | Any | CommonJS |
.js | "module" | ESM |
.js | "commonjs" or absent | CommonJS |
.js | No package.json | CommonJS (with syntax detection fallback) |
Import Forms
| Form | Syntax | Use Case |
|---|---|---|
| Named | import { foo } from './mod.js' | Specific exports |
| Default | import foo from './mod.js' | Single primary export |
| Namespace | import * as mod from './mod.js' | All exports as object |
| Side-effect | import './mod.js' | Execute module, no bindings |
| Dynamic | const mod = await import('./mod.js') | Conditional or lazy loading |
Export Forms
| Form | Syntax | Notes |
|---|---|---|
| Named | export const foo = 1 | Multiple per module |
| Default | export default function() | One per module |
| List | export { a, b } | Export existing bindings |
| Re-export | export { a } from './mod.js' | Forward exports |
CommonJS vs ESM Differences
| Aspect | CommonJS | ESM |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Loading | Synchronous | Asynchronous |
| Extension required | No | Yes (relative imports) |
__dirname / __filename | Available | Not available (use import.meta) |
| Top-level await | No | Yes |
| Conditional imports | Yes (runtime) | Dynamic import() only |
| Tree shaking | Limited | Yes |
this at top level | module.exports | undefined |
Best Practices
✅ Do This:
import { readFile } from 'node:fs/promises'; // Use node: prefix
import { add } from './math.js'; // Include extension
export default function log() {} // Default for single export
const mod = await import('./feature.js'); // Dynamic for conditional
if (import.meta.main) { main(); } // Entry point check
❌ Don’t Do This:
import fs from 'fs'; // ❌ No node: prefix (works but less clear)
import { add } from './math'; // ❌ Missing .js extension
import { add } from './math/index.js'; // ❌ Directory import not supported
console.log(__dirname); // ❌ Not defined in ESM
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
ERR_REQUIRE_ESM | Requiring ESM from CJS on old Node.js | Upgrade to Node.js 22+ or use dynamic import() |
ERR_MODULE_NOT_FOUND | Missing file extension in relative import | Add .js extension |
__dirname is not defined | ESM does not provide CommonJS globals | Use import.meta.dirname |
| Named import fails from CJS | Static analysis failed | Use default import and access properties |
| Top-level await blocks require | ESM has async dependency | Avoid top-level await in requireable modules |
"type": "module" breaks existing code | All .js files become ESM | Rename legacy files to .cjs |
Real-World Examples
1. ESM Project Configuration
{
"name": "my-app",
"type": "module",
"exports": {
".": "./src/index.js"
}
}
2. Importing Built-in Promises API
import { readFile, writeFile } from 'node:fs/promises';
3. Async Configuration Loading
const config = JSON.parse(await readFile('config.json', 'utf-8'));
4. Conditional Feature Loading
if (featureEnabled) {
const { feature } = await import('./feature.js');
feature.activate();
}
5. Re-exporting from Index
// index.js
export { add, subtract } from './math.js';
export { default as log } from './logger.js';
6. Import.meta.dirname Usage
import { join } from 'node:path';
const configPath = join(import.meta.dirname, 'config.json');
7. Dynamic Import for ESM in CommonJS
async function load() {
const { add } = await import('./math.mjs');
return add(2, 3);
}
8. JSON Import with Attribute
import config from './config.json' with { type: 'json' };
9. Mixed Module Project
{
"type": "module"
}
// legacy.cjs remains CommonJS
// modern.js is ESM
10. Checking if Module is Main
if (import.meta.main) {
console.log('Running as entry point');
}
Visual
Module Format Detection
┌──────────────────────────────────────────────────────────────┐
│ HOW NODE.JS DECIDES ESM OR COMMONJS │
│ │
│ File: utils.mjs │
│ └── Always ESM │
│ │
│ File: utils.cjs │
│ └── Always CommonJS │
│ │
│ File: utils.js │
│ │ │
│ ▼ │
│ Nearest package.json has "type"? │
│ │ │
│ ├── "module" ──▶ ESM │
│ │ │
│ ├── "commonjs" ──▶ CommonJS │
│ │ │
│ └── Absent / no package.json ──▶ CommonJS │
│ (with syntax detection fallback) │
│ │
│ Syntax detection: if CommonJS parsing fails due to │
│ ESM syntax, retry as ESM (Node.js 20.19+). │
└──────────────────────────────────────────────────────────────┘
ESM Loading Pipeline
┌──────────────────────────────────────────────────────────────┐
│ ESM MODULE LOADING PHASES │
│ │
│ 1. RESOLVE │
│ └── Convert specifier to absolute URL │
│ │
│ 2. LOAD │
│ └── Fetch source (file, data:, node:) │
│ │
│ 3. PARSE │
│ └── V8 parses module, records imports/exports │
│ │
│ 4. INSTANTIATE (Linking) │
│ └── Match import bindings to export bindings │
│ Errors thrown here before execution │
│ │
│ 5. EVALUATE │
│ └── Execute module code in dependency order │
│ │
│ Dependencies execute before dependents. │
│ If any dependency throws, dependents do not execute. │
└──────────────────────────────────────────────────────────────┘
Interoperability Matrix
┌──────────────────────────────────────────────────────────────┐
│ IMPORTING BETWEEN MODULE SYSTEMS │
│ │
│ ESM ──import──▶ ESM ✅ Full support │
│ ESM ──import──▶ CommonJS ✅ Default + named (if static) │
│ CommonJS ──require──▶ CJS ✅ Full support │
│ CommonJS ──require──▶ ESM ✅ Node.js 22+ (sync only) │
│ │
│ Limitations: │
│ - CJS require(esm) fails if ESM has top-level await │
│ - Named imports from CJS require static analysis │
│ - Dynamic import() works in both directions │
│ │
│ Universal bridge: const mod = await import('./module.js') │
└──────────────────────────────────────────────────────────────┘
CommonJS vs ESM Execution
┌──────────────────────────────────────────────────────────────┐
│ EXECUTION MODEL COMPARISON │
│ │
│ CommonJS: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ require('./a') │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Execute a.js immediately │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Return module.exports │ │
│ └────────────────────────────────────────────────────────┘ │
│ Synchronous. Side effects happen during require(). │
│ │
│ ESM: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ import { x } from './a.js' │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Build full module graph first (all imports resolved) │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Instantiate: link imports to exports │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Evaluate in dependency order (deps first) │ │
│ └────────────────────────────────────────────────────────┘ │
│ Asynchronous loading. Evaluation after graph is built. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| ESM standard | ECMAScript 2015 module system |
| Node.js ESM support | Experimental 8.5.0, stable 13.2.0 |
.mjs | Always ESM |
.cjs | Always CommonJS |
.js with "type": "module" | ESM |
.js without type or "type": "commonjs" | CommonJS |
| Import syntax | import { x } from './mod.js' |
| Export syntax | export const x = 1 or export default |
| Top-level await | Supported in ESM |
import.meta.dirname | Replaces __dirname |
| Extension in relative imports | Required |
| Tree shaking | Supported in ESM |
require(esm) | Stable in Node.js 25.4.0, default since 22 |
Key takeaways:
- Node.js determines module format by extension and
package.json..mjsis always ESM,.cjsis always CommonJS, and.jsdepends on the nearest"type"field . - ESM uses static imports and exports.
importandexportdeclarations must appear at the top level, enabling static analysis and tree shaking . - Relative imports require file extensions. ESM does not perform extension resolution;
./utils.jsworks,./utilsdoes not . - Top-level await is an ESM-only feature. It allows asynchronous operations at module scope without an async wrapper .
import.metareplaces__dirnameand__filename. Useimport.meta.dirnameandimport.meta.filenamein ESM code .- ESM can import CommonJS. The default import gives access to
module.exports; named imports work only when static analysis succeeds . - CommonJS can require ESM since Node.js 22. Synchronous ESM modules are requireable; top-level await blocks this with
ERR_REQUIRE_ASYNC_MODULE. - Dynamic
import()works in both systems. It is the universal bridge for conditional or lazy loading across module boundaries.
Remember: ESM is the JavaScript standard module system, and Node.js has supported it natively since version 13.2.0. The format of a file is determined by its extension and the nearest package.json "type" field. ESM imports are static and hoisted, enabling better tooling and tree shaking than CommonJS. Top-level await and import.meta are language features unavailable in CommonJS. Interoperability works in both directions on modern Node.js, though top-level await remains a barrier for require(esm). When starting a new project, prefer ESM with "type": "module" in package.json and .js extensions in all relative imports. When maintaining an existing CommonJS project, migrate gradually using .mjs for new ESM files or use dynamic import() as a bridge. The ecosystem is moving toward ESM, and the tooling has caught up.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!