ES Modules (ESM) are the official standardized module system for JavaScript, introduced in ECMAScript 2015. They use import and export statements to share code between files. Unlike older systems like CommonJS, ES Modules are statically analyzable, support tree-shaking, use live bindings, enforce strict mode, and work natively in browsers, Node.js, Deno, and Bun.
ES Modules offer several advantages over CommonJS:
Yes, all modern browsers support ES Modules: Chrome 61+, Firefox 60+, Safari 10.1+, and Edge 16+. This covers over 95% of global browser usage. For legacy browsers, use the nomodule fallback attribute on script tags to serve a bundled version:
<script type="module" src="app.js"></script>
<script nomodule src="app-legacy.js"></script>
| Feature | ES Modules | CommonJS |
|---|---|---|
| Syntax | import / export | require() / module.exports |
| Analysis | Static (parse time) | Dynamic (runtime) |
| Bindings | Live references | Value copies |
| Loading | Asynchronous | Synchronous |
| Strict Mode | Always | Optional |
| Browser | Native | Requires bundler |
It depends on the environment:
.js with "type": "module" in package.json.mjs for individual ESM files.ts / .tsx regardlessAdding type="module" to a script tag tells the browser to treat the script as an ES Module. This enables:
<script type="module">
import { greet } from './utils.js';
greet('World'); // Module scope - no global pollution
</script>
ES Modules are always fetched with CORS (Cross-Origin Resource Sharing) for security. Unlike classic scripts, module scripts cannot be loaded from cross-origin sources without proper CORS headers. This prevents sensitive data leakage through module imports. CDNs serving ESM code must include Access-Control-Allow-Origin headers. This is why CDNs like esm.sh, jspm.io, and unpkg include CORS headers by default.
Import maps are a browser feature that lets you control how module specifiers are resolved. They map bare specifiers like 'lodash' to actual URLs:
<script type="importmap">
{
"imports": {
"lodash": "https://esm.sh/[email protected]"
}
}
</script>
<script type="module">
import { debounce } from 'lodash'; // Just works!
</script>
Use them for: bundler-free development, CDN-based imports, prototyping, or when you want npm-style imports without a build step. Supported in all modern browsers.
Dynamic imports use the import() function syntax, which returns a Promise resolving to the module's namespace object:
// Lazy load on user action
button.addEventListener('click', async () => {
const { showChart } = await import('./chart.js');
showChart(data);
});
// Conditional loading
const i18n = await import(`./locales/${lang}.js`);
// With error handling
try {
const mod = await import('./optional-feature.js');
mod.init();
} catch {
console.log('Feature not available');
}
Unlike static imports, dynamic imports can appear anywhere in code and accept computed strings. They enable code splitting and lazy loading for better performance.
Two approaches:
// Option 1 (recommended): Add to package.json
{ "type": "module" }
// Now all .js files are treated as ESM
// Option 2: Use .mjs extension
// Rename files to .mjs for per-file ESM
// math.mjs, utils.mjs, etc.
The "type": "module" approach is recommended for new projects. Node.js 16+ has stable ESM support; Node.js 24+ is the current LTS with full ESM maturity.
Yes! Starting with Node.js 22 (behind a flag) and enabled by default in Node.js 23+:
// In a CommonJS file (.cjs or no "type": "module")
const { add } = require('./math.mjs'); // Works in Node 23+!
// Limitation: Does NOT work if the ESM module
// uses top-level await. Use dynamic import() instead:
const mod = await import('./async-module.mjs');
This greatly simplifies the CJS/ESM interop story and makes gradual migration much easier.
Use import attributes (stable in Node.js 22+, standardized in ES2025):
// Recommended: Import attributes (ES2025)
import config from './config.json' with { type: 'json' };
// Workaround for older Node.js:
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const config = require('./config.json');
// Or read manually:
import { readFile } from 'node:fs/promises';
const config = JSON.parse(
await readFile(new URL('./config.json', import.meta.url), 'utf8')
);
// Node.js 21.2+ (recommended)
console.log(import.meta.dirname); // /path/to/directory
console.log(import.meta.filename); // /path/to/file.js
// Older Node.js (manual derivation)
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Choose based on your target environment:
// tsconfig.json for most projects
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022"
}
}
When using "moduleResolution": "nodenext", TypeScript requires .js extensions in imports even for .ts source files:
// You write this in TypeScript:
import { foo } from './utils.js'; // .js, not .ts!
// TypeScript compiles the import specifier as-is
// Node.js ESM requires explicit extensions to resolve files
// The .js refers to the compiled output file
With "moduleResolution": "bundler", extensions are optional because bundlers handle resolution. This is the simpler option if you use a build tool.
Yes, multiple options exist:
# Node.js 24+ (built-in type stripping)
node script.ts
# Node.js 22-23 (experimental flag)
node --experimental-strip-types script.ts
# Deno (native TypeScript)
deno run script.ts
# Bun (native TypeScript)
bun run script.ts
# tsx (works on any Node.js version)
npx tsx script.ts
Note that Node.js type stripping only removes type annotations - it does not support TypeScript features that require transformation like enums or namespaces.
Not always. You can go bundler-free for:
However, bundlers still provide significant value for production: tree-shaking removes unused code, code splitting creates optimal chunks, minification reduces file sizes, and fewer HTTP requests improve loading performance for large dependency trees. For most production applications, Vite is the recommended bundler.
Recommendations by use case:
Avoid configuring webpack from scratch for new ESM projects - Vite or Rspack are better starting points.
Vitest is recommended for ESM projects:
vi.mock() (auto-hoisted)Jest's ESM support is still experimental and requires NODE_OPTIONS='--experimental-vm-modules'. Module mocking uses the unstable jest.unstable_mockModule() API. For existing Jest projects, migration to Vitest is typically straightforward.
Migrate when:
Do not rush migration for stable production CJS codebases. Node.js 23+ require(esm) means CJS and ESM can coexist indefinitely. Migrate at your own pace.
Yes, several tools can help automate the conversion:
Automated tools handle the common patterns well, but you will still need to manually address: dynamic require() calls, __dirname/__filename replacements, conditional require(), and package.json updates.
Yes, gradual migration is the recommended approach for large codebases:
.mjs extension for new ESM files while keeping existing .js files as CJS// package.json - dual entry points
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
In a browser, add type="module" to your script tag. In Node.js, add "type": "module" to your package.json. That is all the configuration needed to start using import/export syntax.
Yes. Use .mjs for ESM files and .cjs for CJS files, or use conditional exports in package.json. Node.js 23+ makes this even easier with require(esm) support, allowing CJS code to directly require ES Modules.
For runtime loading, ESM and CJS have similar performance. The real advantage is at build time: ESM's static structure enables tree-shaking, which removes unused code and produces smaller bundles. Smaller bundles mean faster download and parsing in browsers. ESM also enables parallel loading in browsers via the module graph.
Start with our Fundamentals page for syntax basics, then explore In Practice for real-world usage. The Cheat Sheet is a handy quick reference, and our Glossary defines all the terminology. For external resources, see our Resources page.