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

CommonJS constructs and their ESM replacements Two columns comparing CommonJS constructs on the left — require, module.exports, __dirname, __filename — with their ESM equivalents on the right — import, export, and values derived from import.meta.url. CommonJS ESM equivalent require('mod') module.exports = {} __dirname / __filename synchronous, dynamic scope import mod from 'mod' export { thing } derived from import.meta.url static, ahead-of-time linked

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 from package.json, so Node.js parses .js files as CommonJS by default and rejects import syntax.

Fix: Confirm "type": "module" is present, or rename the file to .mjs if you cannot set type package-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 defined at runtime, even though the import statements work fine.

Root cause: __dirname and __filename are 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.url using fileURLToPath, 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 populated module.exports; ESM’s live-binding model handles circular imports differently and can surface ReferenceError for 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 older assert { 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 become async up the call stack.
  • TypeScript’s nodenext module resolution. Relative imports in .ts source must include an explicit .js extension (import './util.js') even though the source file is util.ts — this refers to the compiled output extension, not the source file’s.
  • pnpm and Yarn PnP consumers of the CJS build. Test the require condition under both package managers; strict linking can surface subpath resolution gaps that a flatter node_modules layout 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.



↑ Back to Understanding ESM vs CJS Module Formats