JavaScript Modules: The 2026 Guide (ESM vs CommonJS)
Stop confusing require and import. Here's the complete picture.
Two Module Systems
CommonJS (Node.js legacy) ES Modules (Modern standard)
───────────────────── ───────────────────────────
require() import
module.exports export
Synchronous Asynchronous
Node.js default Browser + Node.js
.cjs extension .mjs extension
CommonJS (CJS)
// math.js
function add(a, b) { return a + b; }
function multiply(a, b) { return a * b; }
module.exports = { add, multiply };
// Or single export:
// module.exports = function() { ... };
// app.js
const { add, multiply } = require('./math');
const utils = require('./utils'); // Import everything
console.log(add(2, 3)); // 5
ES Modules (ESM) — Use This!
// math.mjs (or math.js with "type": "module" in package.json)
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
// Named exports (recommended for utilities)
export const PI = 3.14159;
// Default export (one per file — for main class/component)
export default class Calculator {
// ...
}
// app.mjs
import { add, multiply, PI } from './math.js';
import Calculator from './calculator.js'; // Default import
import * as math from './math.js'; // Namespace import
console.log(add(2, 3)); // 5
console.log(PI); // 3.14159
Key Differences
| Feature | CommonJS | ESM |
|---|---|---|
| Loading | Synchronous | Asynchronous |
this at top level |
{} |
undefined |
| Can use in browser? | No (needs bundler) | Yes (natively) |
| Tree-shakeable? | No | Yes |
| Top-level await? | No | ✅ Yes! |
| Dynamic imports? | require() |
import() |
Dynamic Imports — Code Splitting
// Load only when needed (lazy loading)
async function loadDashboard() {
const { renderDashboard } = await import('./dashboard.js');
renderDashboard();
}
// Route-based code splitting (React example)
const Home = lazy(() => import('./pages/Home'));
const Settings = lazy(() => import('./pages/Settings'));
// Conditional imports
if (process.env.NODE_ENV === 'development') {
const devTools = await import('./dev-tools.js');
devTools.init();
}
Re-exports (Barrel Files)
// utils/index.js — barrel file
export { formatDate } from './date.js';
export { slugify } from './string.js';
export { debounce, throttle } from './fn.js';
// Now consumers can:
import { formatDate, slugify } from './utils/index.js';
// or just: import { formatDate } from './utils';
Import Maps (Browser)
<script type="importmap">
{
"imports": {
"lodash": "https://cdn.skypack.dev/lodash",
"react": "https://esm.sh/react@18"
}
}
</script>
<script type="module">
import _ from 'lodash'; // Works without bundler!
import React from 'react';
</script>
Package.json Configuration
{
"name": "my-package",
"version": "1.0.0",
"type": "module", // ← Treat all .js files as ESM!
"exports": { // ← Control what's accessible externally
".": "./dist/index.js",
"./utils": "./dist/utils.js"
},
"imports": { // ← Internal path aliases (Node.js)
"#config": "./src/config.js",
"#utils": "./src/utils/"
},
"main": "./dist/index.js", // For CJS consumers
"module": "./dist/index.esm.js", // For ESM consumers (bundlers)
"types": "./dist/index.d.ts" // TypeScript types
}
Interop (Mixing CJS and ESM)
// Importing CJS into ESM
import pkg from 'commonjs-package'; // Default import works
import { namedExport } from 'cjs-pkg'; // May not work if CJS doesn't export named!
// Importing ESM into CJS
// ❌ You can't require() an ESM-only package!
// Solution: Dynamic import (works in both):
(async () => {
const esmModule = await import('esm-package');
console.log(esmModule.default);
})();
Practical Patterns
Singleton Pattern
// db.js — only runs once, cached by module system
let connection = null;
export async function getConnection() {
if (!connection) {
connection = await createConnection();
}
return connection;
}
// Every file that imports this gets the SAME connection instance
Config Pattern
// config.js
const env = process.env.NODE_ENV || 'development';
export const config = Object.freeze({
isDev: env === 'development',
isProd: env === 'production',
apiBase: env === 'production' ? 'https://api.example.com' : 'http://localhost:3000',
dbUrl: process.env.DATABASE_URL!,
port: parseInt(process.env.PORT || '3000'),
});
Type-Only Imports (TypeScript)
// type.ts
export interface User { name: string; age: number; }
// app.ts
import type { User } from './types.js'; // Erased at compile time!
import { someFunc, type Result } from './lib'; // Mixed import
Are you still using CommonJS or fully migrated to ESM?
Follow @armorbreak for more JavaScript content.
Top comments (0)