As JavaScript evolves, ECMAScript Modules (ESM) have become the modern standard for writing modular code. ESM supports native imports in browsers, allows for asynchronous loading, and enables better tree-shaking during bundling. If you're transitioning from CommonJS (require, module.exports), this guide shows how to update your modules and avoid common pitfalls.
Converting require to import
Named Imports
Before (CommonJS):
const { readFileSync } = require('fs');
const { myFunction1, myFunction2} = require('./myModule');
After (ESM):
import { readFileSync } from 'fs';
import { myFunction } from './myModule.js';
import { myFunction1, myFunction2} from './myModule.js';
Converting module.exports and exports to export
Default Exports
Before (CommonJS):
module.exports = function sayHello() {
console.log('Hello');
};
After (ESM):
export default function sayHello() {
console.log('Hello');
}
Named Exports
Before (CommonJS):
exports.greet = function () {
console.log('Hi');
};
exports.farewell = function () {
console.log('Bye');
};
After (ESM):
export function greet() {
console.log('Hi');
}
export function farewell() {
console.log('Bye');
}
Importing CommonJS Modules in ESM
If you're working in ESM and need to load a CommonJS module (like many older npm packages), use dynamic import():
const lodash = await import('lodash');
Note: Top-level await requires Node.js v16+ and must be in an ESM module (.mjs or with "type": "module" in your package.json).
Handling Mixed Exports (Default + Named)
Some libraries export both a default and named exports (e.g., react or chalk):
import chalk, { red, bold } from 'chalk';
If you're unsure what's being exported, check the library's documentation or inspect it:
const chalk = await import('chalk');
console.log(Object.keys(chalk));
How to Enable ESM in Your Project
Option 1: Use .mjs Files
Rename your files to use the .mjs extension:
mv index.js index.mjs
Then use ESM syntax directly.
Option 2: Update package.json
Add "type": "module" to your package.json:
{
"type": "module"
}
Now you can use .js with ESM imports/exports.
Note: Once this is set, all .js files in your project are interpreted as ESM. If you need CommonJS elsewhere (e.g., in config files), rename them to .cjs.
Combinations of CommonJS and ESM
1. module.exports = { a, b }
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
module.exports = { add, subtract };
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
2. module.exports = function
module.exports = function greet(name) {
return `Hello, ${name}`;
};
export default function greet(name) {
return `Hello, ${name}`;
}
3. exports.a = ..., exports.b = ...
exports.trim = str => str.trim();
exports.upper = str => str.toUpperCase();
export const trim = str => str.trim();
export const upper = str => str.toUpperCase();
4. module.exports = { default: fn, x: y }
function core() {}
function helper() {}
module.exports = { default: core, helper };
export default function core() {}
export function helper() {}
5. Mutated module.exports
module.exports = {};
module.exports.start = () => {};
module.exports.stop = () => {};
export function start() {}
export function stop() {}
6. Class Export
class Logger {
info(msg) { console.log(msg); }
}
module.exports = Logger;
export default class Logger {
info(msg) { console.log(msg); }
}
7. Named Constants + Default
const BASE_URL = 'https://api.com';
function fetchData() {}
module.exports = {
default: fetchData,
BASE_URL,
};
const BASE_URL = 'https://api.com';
function fetchData() {}
export default fetchData;
export { BASE_URL };
8. A Single Export for Everything
function doSomething() {
console.log('Default function');
}
function helperOne() {
console.log('Helper one');
}
function helperTwo() {
console.log('Helper two');
}
module.exports = {
default: doSomething,
helperOne,
helperTwo
};
export { doSomething as default, helperOne, helperTwo };
export default doSomething;
export { helperOne, helperTwo };
9. Hybrid Module (Works with ESM and CommonJS)
While it's technically possible to write code that works in both module systems, it's rarely recommended for production code. Here's an example:
const BASE_URL = 'https://api.example.com';
function get(endpoint) {
return fetch(`${BASE_URL}/${endpoint}`).then(res => res.json());
}
function post(endpoint, data) {
return fetch(`${BASE_URL}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).then(res => res.json());
}
const apiClient = { get, post };
export { get, post, BASE_URL };
export default apiClient;
if (typeof module !== 'undefined' && module.exports) {
module.exports = apiClient;
module.exports.default = apiClient;
module.exports.get = get;
module.exports.post = post;
module.exports.BASE_URL = BASE_URL;
}
Important: This approach has limitations and won't work reliably across all environments. The typeof module !== 'undefined' check can be problematic with modern bundlers and may not behave as expected in browser environments.
Better alternatives:
Use a bundler — Tools like Webpack, Rollup, or esbuild can generate proper dual-format builds (ESM + CommonJS) from a single ESM source. This is the recommended approach for libraries.
Publish dual formats — Write your code in ESM, then use a build tool to output both ESM and CommonJS versions:
{
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"exports": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
ESM-only — For new projects, consider going ESM-only. Most modern environments support it, and you can always use dynamic import() in CommonJS if needed.
When the hybrid approach might make sense:
For anything you're publishing to npm or using in production, stick with proper tooling to create a true Universal Module Definition (UMD) build or dual-format distribution. Modern bundlers handle the complexity of detecting the runtime environment (CommonJS, AMD, or browser globals) and adapting accordingly, without the fragility of manual runtime checks.
Common Pitfalls
Here are common issues developers run into when migrating:
Missing __dirname and __filename
One of the most common gotchas when migrating to ESM: __dirname and __filename don't exist in ESM modules.
Before (CommonJS):
console.log(__dirname);
console.log(__filename);
After (ESM):
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__dirname);
console.log(__filename);
What's happening here?
import.meta.url gives you the file's URL as a string (e.g., file:///Users/you/project/src/index.js)
fileURLToPath() converts that URL to a normal file path
dirname() extracts the directory path from the file path
Quick tip: If you only need the directory for constructing paths to other files, you can often use new URL() instead:
import { readFileSync } from 'fs';
const configPath = new URL('./config.json', import.meta.url);
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
This approach is cleaner and doesn't require the path conversions.
Missing File Extensions
ESM requires full paths including file extensions.
import myUtil from './util';
import myUtil from './util.js';
Mixing require with import
Don’t mix module systems in the same file. Pick one, ideally ESM for new code.
Forgetting Top-Level await Limitations
Only available in ESM files. If using top-level await, ensure you're inside an ESM module or wrap the code in an async function.
I hope this can clear up any confusion for returning developers who just needed a primer to peruse or new developers trying to clear up their confusion as they navigate older code bases.
Resources