Modern JavaScript development requires understanding ES modules--the official standard for packaging code for reuse. This guide covers everything you need to know to effectively use ECMAScript modules (ESM) in your Node.js projects, from basic syntax to advanced patterns that improve code organization and performance.
What you'll learn:
- ES module fundamentals and syntax
- Enabling ESM in Node.js projects
- Import/export patterns and best practices
- Interoperability with CommonJS
- Module resolution and specifiers
- Performance considerations
Understanding ES Modules in Node.js
ECMAScript modules are the official standard format for packaging JavaScript code for reuse. Node.js fully supports ES modules alongside its original CommonJS module system, providing developers with flexibility while encouraging adoption of modern standards.
The Evolution of JavaScript Modules
JavaScript programs started small, but as applications grew more complex, the need for a standardized module system became critical. Node.js addressed this early with CommonJS, which became the de facto standard for server-side JavaScript. However, the browser environment lacked native module support, leading to various incompatible module systems like AMD (RequireJS).
ES modules represent the official TC39 standard for JavaScript modules, bringing consistency across browser and server environments. All modern browsers now support ES modules natively, enabling optimal loading without transpilation--though bundlers like webpack still provide valuable optimizations like tree-shaking and code splitting.
Why ES Modules Matter for Modern Development
ES modules offer several advantages over CommonJS:
-
Static structure enables optimization: The import/export statements are statically analyzable, allowing bundlers to perform tree-shaking--removing unused code from production bundles.
-
Explicit dependencies: Unlike CommonJS's dynamic require statements, ES module imports are resolved at load time, making the dependency graph clear and predictable.
-
Native browser support: Modern browsers can load ES modules directly using the
<script type="module">tag, reducing the need for build steps. -
Future-proof syntax: ES modules represent the direction of the JavaScript ecosystem, with new frameworks, libraries, and tools increasingly assuming ES module compatibility.
When building modern web applications with Node.js development services, adopting ES modules ensures your codebase remains compatible with the latest tooling and framework updates.
Enabling ES Modules in Node.js
Node.js supports two module systems: CommonJS (the traditional require/module.exports syntax) and ECMAScript modules. You must explicitly indicate which system your code uses through one of three mechanisms.
Method 1: Using the .mjs File Extension
The simplest way to indicate that a file should be treated as an ES module is to use the .mjs file extension:
// math.mjs
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
Method 2: Setting type: "module" in package.json
The most common approach for production projects:
{
"name": "my-esm-project",
"type": "module",
"version": "1.0.0"
}
Method 3: Using the --input-type Flag
For command-line execution:
node --input-type=module --eval "import('./math.mjs').then(m => console.log(m.add(2, 3)))"
Explicitly Using CommonJS
If your project uses ES modules by default but you need to include CommonJS code:
- Use the
.cjsfile extension - Set
"type": "commonjs"in package.json - Use the
--input-type=commonjsflag
Import and Export Syntax
Understanding import and export syntax is fundamental to working effectively with ES modules.
Named Exports
Named exports allow you to export multiple values from a module:
// utils.mjs
export const formatDate = (date) => {
return date.toISOString().split('T')[0];
};
export const capitalize = (str) => {
return str.charAt(0).toUpperCase() + str.slice(1);
};
// Import specific named exports
import { formatDate, capitalize } from './utils.mjs';
// Rename imports to avoid conflicts
import { formatDate as formatISO, capitalize as titleCase } from './utils.mjs';
Default Exports
Each module can have exactly one default export:
// Logger.mjs
export default class Logger {
constructor(name) {
this.name = name;
}
log(message) {
console.log(`[${this.name}] ${message}`);
}
}
// Import the default export with any name
import Logger from './Logger.mjs';
Combining Named and Default Exports
// math.mjs
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export default function calculate(a, b, operation) {
switch (operation) {
case 'add': return add(a, b);
case 'subtract': return subtract(a, b);
default: throw new Error('Unknown operation');
}
}
// Import both simultaneously
import calculate, { add, subtract } from './math.mjs';
Re-exporting (Aggregating Modules)
// index.mjs
export { add, multiply } from './math.mjs';
export { formatDate } from './date.mjs';
export { Logger } from './logger.mjs';
// Consumer imports from a single entry point
import { add, formatDate, Logger } from './index.mjs';
Re-exporting through barrel files creates a clean public API for your libraries and applications, making it easier to manage dependencies in larger codebases. For more on structuring Node.js projects effectively, explore our guide on building deploying React web applications that demonstrates similar organizational patterns.
Module Specifiers and Resolution
Types of Specifiers
Relative specifiers start with ./, ../, or /:
import { helper } from './utils/helper.mjs';
import { config } from '../shared/config.mjs';
For relative specifiers, the file extension is mandatory--you cannot omit the .mjs or .js extension.
Bare specifiers reference packages by name without any path prefix:
import express from 'express';
import { readFile } from 'fs/promises';
Absolute specifiers use the file: URL scheme to specify an exact path:
import { something } from 'file:///absolute/path/to/module.mjs';
The node: URL Scheme
Node.js provides the node: URL scheme for importing built-in modules, which makes it explicit that you're importing a Node.js built-in rather than a package from node_modules:
import fs from 'node:fs';
import path from 'node:path';
import http from 'node:http';
import { readFile } from 'node:fs/promises';
Import Attributes
When importing JSON or other non-JavaScript resources, you must use import attributes to specify the expected type:
import config from './config.json' with { type: 'json' };
import.meta Properties
The import.meta object provides context-specific information about the current module:
import.meta.url returns the absolute URL of the current module, enabling relative path resolution:
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import.meta.dirname and import.meta.filename provide equivalents to CommonJS's __dirname and __filename directly (Node.js v20.11+):
console.log(import.meta.dirname); // Directory containing this module
console.log(import.meta.filename); // Full path to this module
Interoperability with CommonJS
Node.js provides interoperability between ES modules and CommonJS, allowing gradual migration from legacy codebases while adopting modern module practices.
Importing CommonJS into ES Modules
You can import CommonJS modules into ES modules using standard import syntax:
import crypto from 'crypto';
const hash = crypto.createHash('sha256');
hash.update('data to hash');
console.log(hash.digest('hex'));
When importing a CommonJS module, the entire module.exports object becomes the default export. Node.js also performs static analysis to expose named exports where possible, making the transition smoother for well-structured CommonJS modules.
Using require() in ES Modules
ES modules do not have access to require() by default. However, you can create a require function using module.createRequire():
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const require = createRequire(__filename);
const legacyData = require('./legacy-data.cjs');
This approach is valuable for gradually migrating legacy codebases or when you need to use CommonJS-only packages within an ES module environment.
Key Differences Between ES Modules and CommonJS
| Feature | CommonJS | ES Modules |
|---|---|---|
| Import syntax | require() | import statement |
| Export syntax | exports/module.exports | export statement |
| File extension | .js (implicit) | .mjs or "type": "module" |
| __dirname | Available | import.meta.dirname |
| __filename | Available | import.meta.filename |
| require.resolve | Available | import.meta.resolve() |
| require.cache | Available | Separate ESM cache |
Restrictions in ES Modules
- No require, exports, or module.exports (use import/createRequire)
- No __filename or __dirname (use import.meta properties)
- No require.resolve (use import.meta.resolve())
- No require.cache (ES modules have separate cache system)
- No NODE_PATH (use symlinks if needed)
Understanding these differences is crucial when migrating existing Node.js projects to modern ES module workflows.
Best Practices for ES Modules
Organize with a Clear Directory Structure
src/
├── controllers/
│ ├── user/
│ │ ├── index.mjs
│ │ ├── create.mjs
│ │ └── update.mjs
├── services/
│ ├── database.mjs
│ └── email.mjs
├── utils/
│ └── validation.mjs
└── index.mjs
Each directory's index.mjs acts as a public API, re-exporting only what's intended for external use:
// src/controllers/user/index.mjs
export { default as createUser } from './create.mjs';
export { default as readUser } from './read.mjs';
export { default as updateUser } from './update.mjs';
Use Barrel Files Wisely
Barrel files (index.mjs files that re-export from sibling modules) provide a convenient public API while hiding internal implementation details:
// Good: Barrel exports for stable public APIs
export { formatDate, parseDate, differenceInDays } from './date.mjs';
// Avoid: Re-exporting everything indiscriminately
export * from './internal-utils.mjs'; // Exposes internals
Prefer Named Exports for Utilities
Named exports make it clear what's being imported and support tree-shaking:
// Good: Named exports
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
// Avoid: Default exports for utility modules
export default { add, subtract }; // Prevents tree-shaking
Use Default Exports for Classes and Factories
Classes, constructors, and factory functions typically work well as default exports:
export default class Database {
constructor(config) {
this.config = config;
}
connect() { /* ... */ }
}
Handle Dynamic Imports for Code Splitting
Static imports are resolved at module load time. For on-demand loading, use dynamic imports:
async function loadEditor() {
const { RichTextEditor } = await import('./editor.mjs');
return new RichTextEditor();
}
// Only load the editor when needed
if (userWantsToEdit) {
loadEditor().then(editor => editor.render());
}
Dynamic imports return a Promise and can be used for conditional loading, code splitting, and reducing initial bundle size in large applications.
Leverage Top-Level Await
ES modules support top-level await, allowing async code at the module scope without wrapping in async functions:
// config.mjs
const response = await fetch('https://api.example.com/config');
export const config = await response.json();
// Another module can use the exported config directly
import { config } from './config.mjs';
console.log(config);
Performance Considerations
Native Browser Loading
Modern browsers can load ES modules directly without build tools, enabling optimizations like parallel fetching and streaming evaluation:
<!-- Browser loads these in parallel, evaluates in order -->
<script type="module" src="./main.mjs"></script>
<script type="module" src="./utils.mjs"></script>
The browser's native module loader handles caching, dependency resolution, and efficient fetching based on the module graph.
Tree-Shaking Benefits
Static import/export syntax enables bundlers to identify and remove unused code, significantly reducing production bundle sizes:
// math.mjs
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
export function complexAlgorithm() { /* Large unused function */ }
// Only add gets bundled if this is our import
import { add } from './math.mjs';
This optimization directly improves load times and user experience in production deployments.
Module Caching
Node.js caches ES modules separately from CommonJS modules. This means:
- Modules are parsed once and reused across imports
- Circular dependencies are handled more predictably
- Module state (including variables) is shared across imports
// singleton.mjs
let instance = null;
export function getInstance() {
if (!instance) {
instance = { id: Math.random(), created: Date.now() };
}
return instance;
}
// Both imports get the same instance
import a from './singleton.mjs';
import b from './singleton.mjs';
console.log(a === b); // true
Chunking Strategies
When using bundlers like webpack or Vite, consider chunking strategies for large applications:
- Vendor chunks: Separate third-party code that changes infrequently
- Route-based chunks: Load only what's needed for each page
- Component chunks: Lazy-load large components on demand
// Dynamic imports for route-based code splitting
const routes = {
home: () => import('./routes/Home.mjs'),
about: () => import('./routes/About.mjs'),
dashboard: () => import('./routes/Dashboard.mjs')
};
async function loadRoute(routeName) {
const module = await routes[routeName]();
return module.default();
}
Implementing effective chunking strategies is essential for optimizing performance in complex JavaScript applications. Understanding async programming patterns also helps--learn more about parallelism, concurrency, and async programming in Node.js to build more efficient applications.
Common Pitfalls and How to Avoid Them
Extensionless Imports
Unlike CommonJS, ES modules require file extensions for relative imports:
// Wrong - will fail
import utils from './utils';
// Correct
import utils from './utils.mjs';
This is intentional--it makes module resolution explicit and consistent across environments.
Missing Default Exports
Remember that each module can have only one default export:
// Wrong - can't have two defaults
export default class User { }
export default class Product { }
// Correct - use named exports or split into files
export class User { }
export class Product { }
Circular Dependencies
While ES modules handle circular dependencies better than CommonJS, they still require careful attention:
// a.mjs
import { b } from './b.mjs';
export const a = 'module A';
export function getB() { return b; }
// b.mjs
import { a } from './a.mjs';
export const b = 'module B';
console.log(a); // Works: a is hoisted as an unfinished declaration
When in doubt, restructure to minimize circular dependencies.
TypeScript Considerations
If using TypeScript, ensure your tsconfig.json is configured for ES modules:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler"
}
}
The "bundler" module resolution mode understands modern bundler conventions and works well with ES modules.
Mixing import Styles
Use consistent import styles throughout your codebase:
// Inconsistent - hard to maintain
import fs from 'node:fs';
import { readFile } from 'fs';
// Consistent - clear what's being imported
import fs, { readFile } from 'node:fs';
Consistency in import patterns improves code readability and reduces confusion during code reviews and maintenance.
Why modern JavaScript development relies on ES modules
Tree-Shaking
Bundlers can remove unused code, reducing bundle size and improving load times for end users.
Static Analysis
Clear dependency graphs enable better tooling, IDE support, and code optimization across your codebase.
Native Browser Support
Modern browsers load ES modules directly without transpilation or bundling for simpler applications.
Cross-Environment Consistency
Same syntax works in browsers, Node.js, and other JavaScript environments with seamless interoperability.
Frequently Asked Questions
Can I use ES modules and CommonJS in the same project?
Yes! Node.js provides full interoperability. You can import CommonJS modules into ES modules and vice versa. Use .cjs files for CommonJS or .mjs files for ES modules when your package.json has "type": "module".
What file extension should I use for ES modules?
Use .mjs for explicit ES modules, or use .js with "type": "module" in package.json. The .mjs extension makes it immediately clear which files are modules, but .js with package.json configuration is more common in production projects.
How do I use __dirname and __filename in ES modules?
Use import.meta.dirname and import.meta.filename instead. These are available in Node.js v20.11 and later. For older versions, use: const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename);
Can I dynamically import ES modules?
Yes! Use the dynamic import() function, which returns a Promise. This is useful for code splitting, conditional loading, and reducing initial bundle size. Unlike static imports, dynamic imports can be used conditionally.
What about TypeScript and ES modules?
TypeScript supports ES modules but requires proper configuration. Set "module": "ESNext" and "moduleResolution": "bundler" in your tsconfig.json. Some TypeScript configurations require additional type declarations when importing JSON or other non-JavaScript resources.
Conclusion
ECMAScript modules represent the modern standard for JavaScript code organization, offering improved tooling support, better performance characteristics, and cross-environment consistency. By understanding ES module syntax, interoperability with CommonJS, and best practices for structuring your code, you can build maintainable applications that leverage the full power of modern JavaScript.
Key takeaways:
- Start new projects with ES modules using
"type": "module"in package.json - Use named exports for utilities to enable tree-shaking and smaller bundle sizes
- Use default exports for classes and factories as their primary export
- Structure modules with barrel files for clean public APIs and easier maintenance
- Leverage dynamic imports for code splitting and lazy loading
- Understand CommonJS interoperability for gradual migrations from legacy codebases
The investment in learning ES modules pays dividends through better code organization, improved tooling, smaller production bundles, and future-proof code that will continue to work as the JavaScript ecosystem evolves. Whether you're building custom web applications or maintaining existing Node.js projects, mastering ES modules is essential for modern JavaScript development.
Ready to modernize your development workflow? Our experienced team can help you migrate to ES modules, optimize your codebase, and build scalable applications with modern JavaScript best practices.