Published on

ESM vs CommonJS in 2026 — The Definitive Guide to Node.js Module Interop

Authors

Introduction

In 2026, ESM is the standard, but CommonJS hasn't died. Node.js 22 added the missing link: require(esm). This post covers the complete interop landscape and teaches you to navigate both ecosystems with confidence.

Current State of ESM in Node.js 22

Node.js 22 LTS shipped with major ESM improvements:

  • Native ESM is stable and default for .mjs and "type": "module"
  • require(esm) now works (the missing feature that justified CommonJS)
  • import.meta.resolve() for dynamic imports
  • Top-level await in async contexts
  • Better error messages for ESM/CJS mismatches
// package.json
{
  "type": "module",
  "exports": {
    ".": "./dist/index.js",
    "./cli": "./dist/cli.js"
  }
}

With "type": "module", all .js files are treated as ESM. Use .cjs for CommonJS:

// index.js (ESM)
export function greet(name) {
  return `Hello, ${name}!`;
}

// legacy.cjs (CommonJS)
module.exports = { greet: (name) => `Hello, ${name}!` };

require(esm) in Node 22 — The Missing Piece

The biggest change: CommonJS code can now require() ESM modules:

// api.mjs (ESM)
export async function fetchUser(id) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

// index.cjs (CommonJS)
const { fetchUser } = require('./api.mjs'); // ✓ Works in Node 22!

fetchUser(1).then(user => {
  console.log(user);
});

This enables gradual ESM migration in legacy CommonJS projects. You don't have to convert everything at once.

Limitations:

  • Top-level await in ESM cannot be required (must use async/await)
  • Default exports require destructuring: const { default: api } = require('./api.mjs')
// api.mjs with default export
export default {
  fetchUser: async (id) => { /* ... */ },
};

// index.cjs
const api = require('./api.mjs'); // ✓ Default export comes through
api.fetchUser(1);

package.json exports Field for Dual Publishing

Define entry points for both ESM and CommonJS consumers:

{
  "name": "@repo/utils",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "default": "./dist/index.js"
    },
    "./math": {
      "import": "./dist/math/index.js",
      "require": "./dist/math/index.cjs"
    }
  },
  "files": ["dist"]
}

Consumers get the right format automatically:

// ESM consumer
import { sum } from '@repo/utils';
import { add } from '@repo/utils/math';

// CommonJS consumer
const { sum } = require('@repo/utils');
const { add } = require('@repo/utils/math');

Both work seamlessly without dual installations.

TypeScript moduleResolution: bundler vs node16

TypeScript's moduleResolution affects how imports are resolved:

{
  "compilerOptions": {
    "moduleResolution": "bundler",
    "module": "ESNext",
    "target": "ES2022"
  }
}

bundler (recommended for modern projects):

  • Works with modern package exports
  • Understands conditional exports
  • Simulates bundler behavior
  • Best for most projects in 2026

node16 (if you need strict Node.js compliance):

  • Follows Node.js ESM/CJS resolution exactly
  • More strict than bundler
  • Requires explicit file extensions: import { x } from './module.js'
// With "moduleResolution": "bundler"
import { sum } from '@repo/utils';          // ✓ Works
import { sum } from '@repo/utils/math';     // ✓ Works

// With "moduleResolution": "node16"
import { sum } from '@repo/utils/index.js'; // ✓ Requires extension
import { sum } from '@repo/utils/math.js';  // ✓ Requires extension

For most projects, use "moduleResolution": "bundler" and skip file extensions.

.mts and .cts File Extensions

Signal TypeScript files explicitly with extensions:

  • .tsmodule setting in tsconfig
  • .mts → Always ESM
  • .cts → Always CommonJS
// api.mts (Always ESM, even if tsconfig says otherwise)
export async function fetchUser(id: number) {
  return (await fetch(`/api/users/${id}`)).json();
}

// legacy.cts (Always CommonJS)
export = {
  fetchUser: async (id: number) => {
    return (await fetch(`/api/users/${id}`)).json();
  },
};

// index.ts (Uses tsconfig.json "module" setting)
import { fetchUser } from './api.mts';

Use .mts and .cts to be explicit; use .ts for consistency within a single module type.

Dynamic import() for ESM from CommonJS

Import ESM modules dynamically in CommonJS:

// legacy.cjs (CommonJS)
async function loadAPI() {
  const { fetchUser } = await import('./api.mjs');
  return fetchUser(1);
}

loadAPI().then(user => {
  console.log(user);
});

import() always returns a Promise, even in ESM. It's the escape hatch for lazy loading.

Interop Pitfalls: Default Exports and Named Exports

The trickiest part of ESM/CJS interop is default exports:

// api.mjs (ESM with default export)
export default { fetchUser: async (id) => { /* ... */ } };
export function helper() { }

// Using from CommonJS
const api = require('./api.mjs');        // Default export
const { helper } = require('./api.mjs');  // Named export

// ✓ Both work
console.log(api.fetchUser);  // function
console.log(helper);         // function

But mixing is error-prone. Best practice: avoid default exports entirely.

// Better: Named exports only
// api.mjs
export async function fetchUser(id: number) { }
export async function fetchPost(id: number) { }

// CommonJS requires object destructuring
const { fetchUser, fetchPost } = require('./api.mjs');

// ESM also requires destructuring
import { fetchUser, fetchPost } from './api.mjs';

Named exports are explicit and work the same way in both systems.

Building Libraries for Both ESM and CJS

Use tsup or unbuild to output dual formats:

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

export default defineConfig({
  entry: {
    index: 'src/index.ts',
    math: 'src/math.ts',
  },
  format: ['esm', 'cjs'],
  dts: true,
  splitting: false,
  sourcemap: true,
  clean: true,
});

This generates:

dist/
  ├── index.js (ESM)
  ├── index.cjs (CommonJS)
  ├── index.d.ts (Types)
  ├── math.js (ESM)
  ├── math.cjs (CommonJS)
  └── math.d.ts (Types)

With package.json exports pointing to both formats, you get full compatibility.

Tools: tsup, pkgroll, unbuild

tsup (recommended):

npm install -D tsup
tsup src/index.ts --format esm,cjs --dts

pkgroll (minimal, zero config):

npm install -D pkgroll
pkgroll --dist dist src/index.ts

unbuild (powerful, for large projects):

import { defineBuildConfig } from 'unbuild';

export default defineBuildConfig({
  entries: [
    'src/index',
    'src/cli',
  ],
  declaration: true,
  rollup: {
    emitCJS: true,
  },
});

All three solve the dual-publish problem. tsup is fastest for simple projects.

When to Just Go Full ESM

Consider going full ESM if:

  • Your target is Node.js 18+ only
  • No legacy CommonJS dependencies
  • You control all consumers
  • You're building a new library
{
  "type": "module",
  "engines": { "node": ">=22" },
  "exports": {
    ".": "./dist/index.js"
  }
}

In this case, drop CommonJS entirely. The ecosystem is moving ESM, and declaring "type": "module" is the clearest signal.

Don't go full ESM if:

  • You have CommonJS dependencies
  • Your library is widely used (unknown consumers)
  • You need require() for optional dependencies
  • You're in a large monorepo with mixed modules

ESM/CJS Resolution Flowchart

1. Is the importer ESM (.mjs, .js with "type": "module")?
Yes: Use ESM resolution
     - Try package exports (conditional "import" field)
     - Fall back to package.json "main" field
     - Allow top-level await
No: Use CommonJS resolution
     - Try package exports (conditional "require" field)
     - Fall back to package.json "main" field

2. Can't resolve?
   - Check file extensions (.mjs, .cjs, .js)
   - Check "exports" conditions ("types", "import", "require", "default")
   - Check subpath exports (package.json "exports.{subpath}")

3. Fallback:
   - Use package.json "main" field (CommonJS-style)
   - Last resort: index.js in package root

Checklist

  • Update to Node.js 22 LTS
  • Set "type": "module" in package.json (if starting fresh)
  • Use "moduleResolution": "bundler" in TypeScript
  • Define "exports" field with conditional import/require
  • Avoid default exports; use named exports only
  • Use .mts and .cts for explicit module types
  • Set up dual-format build with tsup/unbuild
  • Test require(esm) for legacy consumers
  • Document ESM/CJS support in README
  • Remove index.js fallback if using explicit exports

Conclusion

ESM is now the standard in Node.js 22, and CommonJS interop is solved. The require(esm) feature, combined with explicit package.json exports, enables gradual migration and dual publishing without pain. For new projects, go full ESM. For legacy projects, use require(esm) and conditional exports to navigate both worlds.