Converting a CommonJS Library to ESM
Migrate a CommonJS library to ESM step by step: swap require for import, fix __dirname, update package.json, and keep a CJS build for existing consumers.
Migrating a library from CommonJS to ESM is not a search-and-replace of require for import — it changes how the module system parses your code, resolves relative paths, and interoperates with dependents. Done carelessly, it breaks every consumer still calling require() on your package with a hard ERR_REQUIRE_ESM. This guide walks through converting a CJS library’s source to native ESM while keeping a working CommonJS build for existing consumers, building on the format differences covered in Understanding ESM vs CJS Module Formats.
Root Cause
CommonJS and ESM are not two syntaxes for the same runtime behavior — they are two different module systems with different loading semantics. CommonJS resolves and executes modules synchronously, and module.exports is a mutable object assigned at any point during execution. ESM is parsed and linked ahead of execution, its imports and exports are statically analyzable bindings (not copies), and its module graph loads asynchronously — which is why __dirname, __filename, and require simply do not exist in an ESM module’s scope. Converting a library means replacing every one of these CJS-only constructs with their ESM equivalents, then deciding how (or whether) to keep serving CJS consumers through a parallel build, since running require() on an ESM file throws ERR_REQUIRE_ESM with no automatic fallback.
Minimal Reproduction
A typical CommonJS library entry point before conversion:
// src/index.js (CommonJS)
const path = require('path');
const fs = require('fs');
function loadConfig(name) {
const configPath = path.join(__dirname, 'configs', `${name}.json`);
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
}
module.exports = { loadConfig };
{
"name": "config-loader",
"version": "1.0.0",
"main": "./src/index.js"
}
Converting only the syntax — replacing require/module.exports with import/export — without addressing __dirname produces a runtime error, because __dirname is undefined in ESM scope:
// src/index.js — naive conversion, still broken
import path from 'path';
import fs from 'fs';
function loadConfig(name) {
const configPath = path.join(__dirname, 'configs', `${name}.json`); // ReferenceError: __dirname is not defined
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
}
export { loadConfig };
SVG: CJS to ESM Conversion Map
Step-by-Step Fix
Step 1 — Set “type”: “module” and update the entry field
{
"name": "config-loader",
"version": "1.0.0",
- "main": "./src/index.js"
+ "type": "module",
+ "main": "./dist/cjs/index.cjs",
+ "exports": {
+ ".": {
+ "types": "./dist/esm/index.d.ts",
+ "import": "./dist/esm/index.js",
+ "require": "./dist/cjs/index.cjs",
+ "default": "./dist/esm/index.js"
+ }
+ }
}
Expected result: every .js file in the project is now parsed as ESM by default; the CJS build is produced separately as .cjs output and routed through the require condition, matching the pattern in Mastering the package.json Exports Field.
Step 2 — Replace require and module.exports with import and export
- const path = require('path');
- const fs = require('fs');
+ import path from 'node:path';
+ import fs from 'node:fs';
function loadConfig(name) {
const configPath = path.join(__dirname, 'configs', `${name}.json`);
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
}
- module.exports = { loadConfig };
+ export { loadConfig };
HAZARD PREVENTION
Symptom:
SyntaxError: Cannot use import statement outside a module.Root cause:
"type": "module"is missing frompackage.json, so Node.js parses.jsfiles as CommonJS by default and rejectsimportsyntax.Fix: Confirm
"type": "module"is present, or rename the file to.mjsif you cannot settypepackage-wide yet.
Step 3 — Fix __dirname and __filename
+ import { fileURLToPath } from 'node:url';
+
+ const __filename = fileURLToPath(import.meta.url);
+ const __dirname = path.dirname(__filename);
function loadConfig(name) {
const configPath = path.join(__dirname, 'configs', `${name}.json`);
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
}
Expected result: loadConfig('production') resolves configs/production.json relative to the module’s own directory, identical to the CJS behavior.
HAZARD PREVENTION
Symptom:
ReferenceError: __dirname is not definedat runtime, even though the import statements work fine.Root cause:
__dirnameand__filenameare CommonJS-module-wrapper injections; ESM has no equivalent globals because a module’s URL, not its filesystem path, is the primary identity ESM tracks.Fix: Derive both from
import.meta.urlusingfileURLToPath, as shown above, at the top of every file that previously relied on them.
Step 4 — Update JSON and dynamic imports
- const configPath = path.join(__dirname, 'configs', `${name}.json`);
- return JSON.parse(fs.readFileSync(configPath, 'utf8'));
+ const configUrl = new URL(`./configs/${name}.json`, import.meta.url);
+ const { default: config } = await import(configUrl, { with: { type: 'json' } });
+ return config;
Native ESM JSON imports require the with { type: 'json' } import attribute (stable since Node.js 20.10, replacing the earlier experimental assert syntax). This step is optional — reading and parsing the file manually with fs.readFileSync also works and avoids the import-attribute version requirement.
Step 5 — Keep a CJS build for existing consumers
Use a bundler that emits both formats from the ESM source, such as tsup:
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.js'],
format: ['esm', 'cjs'],
dts: false,
clean: true,
});
npm run build
# dist/esm/index.js <- native ESM, used by "import" condition
# dist/cjs/index.cjs <- transpiled CJS, used by "require" condition
Expected result: require('config-loader') continues to work via the generated dist/cjs/index.cjs, while new consumers importing the package natively load dist/esm/index.js directly — the migration is invisible to existing users.
Verification
# Confirm ESM entry loads correctly
node --input-type=module --eval "import('config-loader').then(m => console.log(m.loadConfig('production')))"
# Confirm CJS consumers still work
node --eval "const { loadConfig } = require('config-loader'); console.log(loadConfig('production'))"
# Validate exports map and types
npx publint --strict
npx attw --pack .
Expected output confirming both formats resolve:
$ node --input-type=module --eval "import('config-loader').then(m => console.log(typeof m.loadConfig))"
function
$ node --eval "console.log(typeof require('config-loader').loadConfig)"
function
Edge Cases / Gotchas
- Circular requires that relied on CJS’s partial exports. CommonJS allows a circular
require()to return a partially populatedmodule.exports; ESM’s live-binding model handles circular imports differently and can surfaceReferenceErrorfor values accessed before initialization. Refactor circular dependencies before converting, rather than after. - JSON imports on Node.js versions before 20.10. The
with { type: 'json' }syntax is not available; use the olderassert { type: 'json' }for Node 17.5–20.9, or read the file manually for maximum compatibility. - Dynamic
require()calls with computed paths.require(variablePath)has no direct ESM equivalent —import()is always asynchronous. Any code that relied on synchronous, computed-path loading needs to becomeasyncup the call stack. - TypeScript’s
nodenextmodule resolution. Relative imports in.tssource must include an explicit.jsextension (import './util.js') even though the source file isutil.ts— this refers to the compiled output extension, not the source file’s. - pnpm and Yarn PnP consumers of the CJS build. Test the
requirecondition under both package managers; strict linking can surface subpath resolution gaps that a flatternode_moduleslayout masked previously.
Frequently Asked Questions
Do I have to drop CommonJS support entirely to migrate to ESM?
No. The migration described here converts your source to ESM but keeps publishing a CJS build alongside it through the exports field, so existing require() consumers are unaffected while new code is written and maintained as native ESM.
What replaces __dirname and __filename in ESM source?
Derive them from import.meta.url: const __filename = fileURLToPath(import.meta.url) and const __dirname = path.dirname(__filename), using the fileURLToPath helper from the built-in url module. These are not available as globals in ESM.
Can I import a CommonJS dependency from my new ESM source?
Yes. Node.js allows ESM to import CJS modules directly via default import interop: import pkg from 'cjs-dependency'. Named exports only work if the CJS module is one Node.js can statically analyze (module.exports assigned as an object literal); otherwise destructure from the default import instead.
Do I need to rename my source files to .mjs?
Not if you set "type": "module" in package.json — that makes .js mean ESM project-wide. Rename to .mjs only if you need ESM and CJS files to coexist in the same directory without a type field boundary, such as a single build script alongside a dual-format dist output.
Will TypeScript compile my converted ESM source without changes?
You need module and moduleResolution set to a Node-ESM-aware value (nodenext or bundler) in tsconfig.json, and relative import specifiers in .ts source must include the .js extension (import './util.js', not './util') for nodenext to emit correct output.
Related
- Fixing require() Errors in Pure ESM Packages — what to do when a consumer hits
ERR_REQUIRE_ESMagainst a package that has already fully migrated with no CJS fallback. - Mastering the package.json Exports Field — the condition-map pattern used in Step 1 to route ESM and CJS consumers to the correct build.
- How to Configure Node.js for Native ESM Support — the runtime configuration prerequisites this migration assumes are already in place.