JavaScript has two major module systems: ES Modules (the modern standard) and CommonJS (the Node.js original). Understanding both — and when to use each — is essential for every JavaScript and Node.js developer.
Quick-reference table
| Feature | ES Modules (ESM) | CommonJS (CJS) |
|---|---|---|
| Syntax | import / export |
require() / module.exports |
| Loading | Static, compile-time | Dynamic, runtime |
Top-level await |
✅ Supported | ❌ Not supported |
| Tree shaking | ✅ Yes (bundlers) | ❌ Harder |
| Browser native | ✅ Yes (type="module") |
❌ No |
| Node.js | ✅ .mjs or "type":"module" |
✅ .cjs or default |
| Interop | import cjs from 'pkg' works |
createRequire needed for ESM |
ES Modules — named exports
Named exports let a module expose multiple values. Each must be imported by its exact name.
// math.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export const PI = 3.14159;
// app.js
import { add, PI } from './math.js';
console.log(add(2, 3)); // 5
console.log(PI); // 3.14159
You can rename on import with as:
import { add as sum, PI as pi } from './math.js';
console.log(sum(1, 2)); // 3
ES Modules — default export
Each module can have one default export. It can be imported under any name.
// greet.js
export default function greet(name) {
return `Hello, ${name}!`;
}
// app.js
import greet from './greet.js';
import sayHello from './greet.js'; // same thing, different name
console.log(greet('World')); // Hello, World!
console.log(sayHello('Alice')); // Hello, Alice!
Mixing named and default in one module:
// utils.js
export default class EventEmitter { /* ... */ }
export function debounce(fn, ms) { /* ... */ }
export const VERSION = '1.0.0';
import EventEmitter, { debounce, VERSION } from './utils.js';
Export declaration styles
There are two ways to mark exports:
// Inline (at declaration)
export const BASE_URL = 'https://api.example.com';
export function fetchUser(id) { /* ... */ }
// Grouped (at the end — easier to see what's public)
const BASE_URL = 'https://api.example.com';
function fetchUser(id) { /* ... */ }
function _validateId(id) { /* private, not exported */ }
export { BASE_URL, fetchUser };
The grouped style is preferred in large modules because it creates a clear "public API" section at the bottom.
Re-exporting (barrel exports)
A common pattern is an index.js that re-exports from multiple files, giving consumers a single import point:
src/
utils/
string.js
number.js
date.js
index.js ← barrel file
// utils/index.js
export { capitalize, truncate } from './string.js';
export { clamp, roundTo } from './number.js';
export { formatDate, diffDays } from './date.js';
// Anywhere in the app
import { capitalize, formatDate } from './utils/index.js';
You can also re-export a default:
export { default as Logger } from './logger.js';
Dynamic imports
Static import at the top of a file runs at load time. Dynamic import (import()) is a function that loads a module on-demand and returns a Promise — ideal for code splitting and conditional loading.
// Load a heavy library only when needed
async function loadChart() {
const { Chart } = await import('./chart.js');
return new Chart(data);
}
// Conditional feature (tree-shaking won't apply here)
const locale = navigator.language.startsWith('fr') ? 'fr' : 'en';
const messages = await import(`./locales/${locale}.js`);
React lazy loading uses dynamic import under the hood:
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));
Namespace imports
Import everything from a module as a single object:
import * as MathUtils from './math.js';
console.log(MathUtils.add(1, 2)); // 3
console.log(MathUtils.PI); // 3.14159
Useful when consuming third-party modules where you want the package name as a namespace, or when you don't control what gets added to the module.
CommonJS — require / module.exports
CommonJS is the original Node.js module system. Files are evaluated synchronously, and require() can appear anywhere (even inside conditionals).
// math.js
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
const PI = 3.14159;
module.exports = { add, subtract, PI };
// app.js
const { add, PI } = require('./math');
console.log(add(2, 3)); // 5
Single-function export:
// greet.js
module.exports = function greet(name) {
return `Hello, ${name}!`;
};
const greet = require('./greet');
console.log(greet('World')); // Hello, World!
Conditional require (common for plugins):
let config;
if (process.env.NODE_ENV === 'production') {
config = require('./config.prod');
} else {
config = require('./config.dev');
}
CommonJS vs ES Modules in Node.js
Node.js supports both. How Node decides which to use:
| File extension | Module system |
|---|---|
.cjs |
Always CommonJS |
.mjs |
Always ES Module |
.js |
Depends on nearest package.json "type" field |
// package.json
{
"type": "module" // → all .js files are ESM
}
{
"type": "commonjs" // → default; all .js files are CJS
}
When writing a library that needs to support both, generate two outputs:
{
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
Interop: using CJS from ESM and vice versa
CJS from ESM — default import works:
// ESM file
import lodash from 'lodash'; // default import of module.exports
const { cloneDeep } = lodash;
// Or named-style (bundlers usually handle this)
import { cloneDeep } from 'lodash';
ESM from CJS — requires dynamic import because require() is synchronous but ESM evaluation is asynchronous:
// CJS file
const run = async () => {
const { myFn } = await import('./esm-module.mjs');
myFn();
};
run();
Or use createRequire from module:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const legacyLib = require('./legacy-commonjs');
import.meta — ESM metadata
In ES Modules, import.meta replaces Node's __filename and __dirname:
// ESM equivalent of __filename / __dirname
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const configPath = join(__dirname, 'config.json');
import.meta.url is also available in the browser:
// In a browser ES Module
console.log(import.meta.url); // https://example.com/js/app.js
Browser ES Modules
Use type="module" on <script> tags:
<script type="module" src="./app.js"></script>
<script type="module">
import { add } from './math.js';
console.log(add(1, 2));
</script>
Key browser behaviours:
- Deferred by default — runs after HTML is parsed (no need for
defer) - CORS required — imports must come from the same origin or include CORS headers (can't use
file://without a server) - Cached — each URL is fetched and evaluated only once per page
- Strict mode — modules always run in strict mode
Use an import map to alias bare specifiers in the browser:
<script type="importmap">
{
"imports": {
"lodash": "https://cdn.jsdelivr.net/npm/lodash-es@4/lodash.js"
}
}
</script>
<script type="module">
import { cloneDeep } from 'lodash'; // works!
</script>
Tree shaking
Bundlers (Webpack, Rollup, Vite) can eliminate unused exports from ES Modules — a process called tree shaking.
// utils.js — 3 exports
export function a() { return 'a'; }
export function b() { return 'b'; }
export function c() { return 'c'; }
// app.js — only imports `a`
import { a } from './utils.js';
After bundling, b and c are removed from the output because they're never imported. Tree shaking works on ES Modules because their imports are static — the bundler can analyse them without running the code.
CommonJS require() is dynamic, so bundlers can't reliably tree-shake CJS. This is one of the main reasons libraries are migrating to ESM.
Module patterns quick reference
| Pattern | How |
|---|---|
| Named exports | export const x = ... |
| Default export | export default function() {} |
| Rename on export | export { fn as myFn } |
| Barrel re-export | export { x } from './x.js' |
| Re-export default | export { default as Foo } from './foo.js' |
| Dynamic import | const m = await import('./m.js') |
| Namespace import | import * as Utils from './utils.js' |
| Side-effect only | import './polyfill.js' |
| CJS export | module.exports = { ... } |
| CJS import | const x = require('./x') |
7 common mistakes
| Mistake | Why it breaks | Fix |
|---|---|---|
Missing .js extension in ESM |
Node ESM resolves exactly as written | Always add .js (even for TypeScript source — use .js as the emit extension) |
import inside if |
Syntax error — static import must be top-level |
Use dynamic import() inside conditionals |
require() in .mjs |
Not defined in ES Modules | Use import or createRequire |
__dirname in ESM |
Not defined | Use fileURLToPath(import.meta.url) + dirname() |
| Circular imports | Can lead to undefined values | Restructure: extract shared code to a third module |
Mixing export default and module.exports |
module.exports overwrites everything in CJS |
Choose one system per file |
| Named import of a CJS default | import { named } from 'cjs-pkg' may be undefined |
Import the default and destructure: import cjs from 'pkg'; const { named } = cjs; |
6 FAQ
Q: Should I use ESM or CommonJS for a new Node.js project?
A: ESM. It's the standard, supported natively in Node 12+ and all modern browsers. Use "type": "module" in package.json. CommonJS is still fine for simple scripts or when your dependencies are CJS-only.
Q: Can I use await at the top level of an ES Module?
A: Yes — top-level await is supported in ES Modules (Node 14.8+ and all modern browsers). In CommonJS you must wrap code in an async function.
// ESM — this works
const data = await fetch('https://api.example.com/data').then(r => r.json());
Q: Why does my bundler bundle things that I didn't import?
A: If a module has side effects (it runs code when required/imported, not just defines exports), bundlers include it even if you don't use its exports. Mark side-effect-free packages in package.json: "sideEffects": false.
Q: What's the difference between import x from and import * as x from?
A: import x from gets the default export. import * as x from creates a namespace object with all named exports (and x.default for the default export). Don't use namespace imports for tree-shaking-sensitive code.
Q: How do I use an ESM package in a CommonJS project?
A: Use dynamic import() in an async context, since require() can't load ES Modules synchronously. Alternatively, configure your bundler (Webpack/Rollup) to handle the interop.
Q: What is "exports" in package.json?
A: The "exports" field (Node 12+) lets a package define multiple entry points and conditional exports (import vs require). It also blocks access to internal files not explicitly listed — a security and encapsulation improvement over the older "main" field alone.
{
"exports": {
".": { "import": "./esm/index.js", "require": "./cjs/index.js" },
"./utils": { "import": "./esm/utils.js", "require": "./cjs/utils.js" }
}
}