The Practical Developer

CommonJS to ESM Migration: Move Your Node.js Project Without Breaking Everything

Adding "type": "module" to your package.json breaks imports, mocks, and dynamic requires in ways that are hard to predict. This guide walks through the practical migration path: incremental adoption, dual-package patterns, testing compatibility, and the traps that catch every team the first time.

Code editor with multiple file tabs representing a migration process across different module systems

You switched "type": "module" in package.json and now half your imports are broken. __dirname is undefined. require throws. Jest crashes with a cryptic ERR_REQUIRE_ESM. Your CI pipeline is red and you are wondering if ESM is just not ready yet.

It is ready. But the migration path has traps that the Node.js docs do not warn you about.

This post covers the actual migration strategy that works for production codebases: how to ship both formats during the transition, which patterns break, how to fix your test runner, and what you gain when you get there.

Step zero: Understand what actually changes

ESM and CommonJS are not just different syntax. They are fundamentally different module systems with different resolution algorithms, different lifecycle semantics, and different security models.

PropertyCommonJSESM
ResolutionSynchronous, require() returns the moduleAsynchronous, import is statically analyzed
File extensionOptional (resolves .js, .json, .node)Required (must include full path + extension)
__dirnameAvailable globallyNot available (use import.meta.url)
Top-level awaitNot supportedSupported
Circular depsReturns partial exportsReturns reference (usually safer)
Strict modeOpt-in via "use strict"Always strict

The most painful difference for migration: ESM does not resolve bare extensionless imports.

// CommonJS - works fine
const db = require('./db');

// ESM - ERR_MODULE_NOT_FOUND
import db from './db';

// ESM - correct
import db from './db.js';

If your project has hundreds of ./utils, ./models, ./config imports without extensions, every single one breaks. That is the first wall teams hit.

Do not flip "type": "module" globally. It is a all-or-nothing flag that leaves you with no fallback when something breaks. Instead, use the dual-format approach:

  1. Keep "type": "commonjs" (or omit it, since CJS is the default)
  2. Convert files one at a time by renaming them to .mjs
  3. Run a mixed codebase where .mjs files use ESM and .js files stay CJS
  4. Only flip "type": "module" when every file is converted, then rename .mjs back to .js

This works because Node.js determines the module format per-file:

  • .mjs is always ESM
  • .cjs is always CommonJS
  • .js follows package.json’s "type" field

The incremental approach lets you ship to production every day during the migration. Each pull request converts a few files and validates them through your test suite.

Example: converting one module at a time

// Before: src/db/connection.js (CommonJS)
const { Pool } = require('pg');
const config = require('./config');

const pool = new Pool(config.db);

module.exports = { pool, query: pool.query.bind(pool) };

Rename to src/db/connection.mjs and fix the imports:

// After: src/db/connection.mjs (ESM)
import { Pool } from 'pg';
import config from './config.js'; // note the .js extension

const pool = new Pool(config.db);

export { pool, query: pool.query.bind(pool) };

Any .js file that require()s this module will now fail, because require() cannot load ESM modules synchronously. But you can use dynamic import() in your CJS files:

// src/legacy/handler.js (CommonJS) - still works
const { pool } = await import('../db/connection.mjs');

This is technically usable but ugly. The better approach: after converting a leaf module (pure functions, utilities, types), convert its consumers next, working outward from the dependency graph.

Strategy 2: The dual-package hazard

If you maintain a library or a shared internal package, you need to ship both CJS and ESM builds. This is called the “dual-package hazard” and it is where most teams get stuck.

The standard approach: set "exports" in package.json to point to both formats.

{
  "name": "@internal/shared-utils",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

The "import" condition is used when the consuming file is ESM; "require" when it is CommonJS. Node.js picks the right one automatically.

But if your package has state (a singleton, a class with static fields, a cached config), the dual-package hazard appears: Node.js may load both copies into memory, one as CJS and one as ESM. Singletons are no longer singletons. Instanceof checks fail. Memory doubles.

The fix: detect the duplicate at build time or force the consumer to pick one format.

// Add a runtime guard in your shared module
// This only works if you control the consuming codebase
if (typeof globalThis.__SHARED_UTILS_LOADED !== 'undefined') {
  throw new Error(
    '@internal/shared-utils loaded in both ESM and CJS. ' +
    'Set "type": "module" in your root package.json ' +
    'to avoid the dual-package hazard.'
  );
}
globalThis.__SHARED_UTILS_LOADED = true;

Or, simpler: only ship one format in internal monorepos. If every package in your workspace is ESM, there is no dual hazard.

The __dirname replacement

Every CommonJS file that uses __dirname or __filename must be updated. The ESM equivalents are:

// CommonJS
const path = require('path');
const dir = __dirname;
const file = __filename;

// ESM
import { fileURLToPath } from 'url';
import path from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

If you use this pattern in many files, extract it:

// src/lib/path-utils.js
import { fileURLToPath } from 'url';
import path from 'path';

export const getDirname = (metaUrl) => path.dirname(fileURLToPath(metaUrl));
export const getFilename = (metaUrl) => fileURLToPath(metaUrl);

Then in every module:

import { getDirname } from './lib/path-utils.js';
const __dirname = getDirname(import.meta.url);

But honestly, you should stop using __dirname for most cases. import.meta.resolve and relative URL constructs are more reliable:

// Instead of: fs.readFileSync(path.join(__dirname, 'schema.sql'))
// Do:
import { readFileSync } from 'fs';
const schema = readFileSync(new URL('./schema.sql', import.meta.url), 'utf-8');

This avoids the __dirname hack entirely and works in Deno and Bun too.

The require dynamic import pattern

Some CJS codebases use dynamic require() for conditional loading:

// CommonJS
let driver;
try {
  driver = require('pg');
} catch {
  driver = require('better-sqlite3');
}

In ESM, replace with dynamic import():

// ESM
let driver;
try {
  driver = await import('pg');
} catch {
  driver = await import('better-sqlite3');
}

Note: import() returns a module namespace object, not the default export. If the CJS module was using module.exports =, the ESM import() wraps it in { default: ... }.

// CJS require('express') returns the Express function directly
// ESM await import('express') returns { default: express, ... }
// So you need:
const express = (await import('express')).default;

Or use the newer createRequire shim if you have too many dynamic requires to convert at once:

import { createRequire } from 'module';
const require = createRequire(import.meta.url);

// Now you can use require() in ESM
const express = require('express');
const { Pool } = require('pg');

createRequire is a bridge, not a destination. Use it during migration and delete it when the conversion is complete.

Testing: the real pain point

Jest has struggled with ESM since its inception. As of Jest 29, ESM support is “experimental” and requires extra flags:

// jest.config.cjs (yes, keep this file as .cjs)
module.exports = {
  transform: {},
  extensionsToTreatAsEsm: ['.mjs'],
  moduleNameMapper: {
    '^(\\.{1,2}/.*)\\.js$': '$1',
  },
};

And you must pass --experimental-vm-modules to Node when running Jest:

// package.json
{
  "scripts": {
    "test": "node --experimental-vm-modules node_modules/.bin/jest"
  }
}

If this sounds fragile, it is. Many teams switch to Vitest for ESM-native testing:

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
  },
});

Vitest handles ESM natively because it uses Vite’s module transform pipeline. Mocking works the same way. No extra flags needed.

// vitest mock - just works with ESM
import { describe, it, expect, vi } from 'vitest';
import { getConfig } from './config.js';

vi.mock('./config.js', () => ({
  getConfig: vi.fn(() => ({ db: { host: 'localhost' } })),
}));

If you are migrating a large Jest codebase, plan for the test runner migration as a separate project phase. Do not combine ESM conversion and test runner migration in the same sprint.

TypeScript considerations

If you use TypeScript, the migration is smoother because TypeScript handles module resolution at compile time. But there are traps:

  1. TypeScript does not rewrite import paths. If you write import { x } from './utils', TypeScript emits import { x } from './utils' in your compiled JS. Node.js ESM will fail because there is no file extension. Fix: set moduleResolution: "node16" or "nodenext" in tsconfig.json. This forces TypeScript to require file extensions in import paths, matching Node.js behavior.
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "outDir": "./dist"
  }
}
  1. ts-node does not support ESM natively. Use tsx instead, which wraps esbuild and handles both formats:
npm install -D tsx
npx tsx src/index.ts
  1. Path aliases. The paths field in tsconfig.json does not emit resolvable paths in compiled JS. If you use @/lib/utils aliases, they break under ESM. Either use a bundler (tsup, esbuild, rolldown) or avoid path aliases entirely during migration.

What you gain

After the migration, here is what you actually get:

  • Top-level await. No more wrapping startup logic in async IIFEs or promise chains. Database connections, config loading, health checks can all sit at the top level of your module.
// ESM top-level await
import { createServer } from './server.js';
import { initDb } from './db.js';

await initDb();
const server = createServer();
server.listen(3000);
  • Static analysis. ESM imports are statically analyzable, which means tree-shaking works. Bundlers can eliminate dead code that CJS never allowed them to touch.

  • Better circular dependency handling. CommonJS returns a partial copy of module.exports when a circular dependency is encountered, which means you might get undefined for exports that haven’t been assigned yet. ESM uses live bindings: the importing module gets a reference that resolves to the final value.

  • Standard compliance. ESM is the JavaScript specification. CommonJS is a Node.js invention. Every new JavaScript runtime (Deno, Bun, Cloudflare Workers) uses ESM as the primary module system.

Practical migration checklist

Before you start, audit your codebase:

  • Count your require() calls. Search require( across the project. Each one needs conversion or a createRequire bridge.
  • Count __dirname and __filename usage. Each needs the import.meta.url pattern.
  • Check your top-level package.json dependencies. Some packages ship CJS-only builds. Check their exports field. If a critical dependency has no ESM entry, you need to use createRequire for it or find a replacement.
  • Audit dynamic requires. Patterns like require(./lang/${locale}) need special handling.
  • Check your test runner. Jest needs --experimental-vm-modules. Vitest works out of the box.
  • Verify your build tool. If you use Webpack, esbuild, or tsup for bundling, check that their ESM output is configured correctly.
  • Check for __dirname in config files. Dotenv configs, migration scripts, seed files often use __dirname to locate files relative to the project root.

Then execute the migration in this order:

  1. Set up the build tool to output ESM (TypeScript nodenext, esbuild format: 'esm')
  2. Convert leaf modules (utilities, constants, types) to .mjs first
  3. Convert their consumers next, working outward
  4. Update test runner configuration
  5. Flip "type": "module" and rename all .mjs back to .js
  6. Remove createRequire bridges
  7. Delete dead conversion code

A note from Yojji

A migration like CommonJS to ESM is rarely a single sprint. It is the kind of methodical, architecture-level work that requires teams to understand the whole dependency graph and keep production stable while changing the foundation underneath. Yojji, a custom software development company founded in 2016, has navigated these transitions across dozens of Node.js codebases, balancing the need for new runtime capabilities with the reality of existing commitments. If your team is planning a module system migration and could use experienced engineers who have done it before, Yojji’s JavaScript and TypeScript teams have been shipping this kind of work since the early days of Node.