StackDevLife
Cover image for: ESM vs CJS — Why Your import Still Breaks in 2026 and How to Finally Fix It
Back to Blog

ESM vs CJS — Why Your import Still Breaks in 2026 and How to Finally Fix It

ERR_REQUIRE_ESM. Missing extensions. No __dirname. The ESM/CJS war is still breaking Node.js projects in 2026. Here's the root cause, all five errors you'll hit, and the exact fixes for each one.

SB

Sandeep Bansod

April 8, 2026
Share:

You install a package. You import it the normal way. Node throws ERR_REQUIRE_ESM. You switch to require(). Now TypeScript complains. You add "type": "module" to package.json. Now half your other imports break. You spend two hours on Stack Overflow reading answers that contradict each other.

This is the ESM/CJS trap. And in 2026, it's still catching experienced developers completely off guard.

Here's everything you need to actually understand what's happening — and the exact fixes for each scenario.

The Root Problem: Two Module Systems That Don't Like Each Other

JavaScript has two completely different module systems running side by side:

CommonJS (CJS) — Node's original system, introduced in 2009. Uses require() and module.exports. Synchronous. Still the default in Node.js if you don't configure anything.

ES Modules (ESM) — The official JavaScript standard, introduced in ES2015. Uses import and export. Asynchronous. The default in browsers, and increasingly the default in Node.js and the npm ecosystem.

The problem: they're fundamentally incompatible at the loading level. CJS loads synchronously. ESM loads asynchronously. This means:

ESM can import from CJS packages — Node does the interop for you. But CJS cannot require() an ESM package — this is a hard error, by design.

This asymmetry is why half your googled fixes don't work. The direction of the import matters.

How Node Decides Which System to Use

Before fixing anything, you need to know how Node chooses CJS or ESM for each file. The rules are straightforward once you see them written out:

TEXT
File extension .mjs  → always ESM
File extension .cjs  → always CJS
File extension .js   → depends on nearest package.json "type" field
  "type": "module"   → .js files are ESM
  "type": "commonjs" → .js files are CJS (this is the default if "type" is missing)

This is the source of most confusion. The same .js file can be CJS or ESM depending entirely on where it lives and what the nearest package.json says.

Bash
# Check what mode a specific file runs in
node --input-type=module  # forces ESM for stdin
node -e "console.log(typeof require)"  # 'function' = CJS, 'undefined' = ESM

The Five Errors You'll Actually Hit

Error 1: ERR_REQUIRE_ESM

Bash
Error [ERR_REQUIRE_ESM]: require() of ES Module not supported.
require() of /node_modules/some-package/index.js is not supported.

This means you're in a CJS context trying to require() a package that only ships ESM. You cannot fix this with a different import syntax. Your options:

JavaScript
// This is what you tried
const pkg = require('esm-only-package');

// Option A: Use dynamic import() — works in CJS files
const pkg = await import('esm-only-package');

// Option B: Convert your file to ESM
// Rename file.js → file.mjs, or add "type": "module" to package.json
import pkg from 'esm-only-package';

// Option C: Find an older version that still ships CJS
npm install some-package@2

Error 2: ERR_MODULE_NOT_FOUND with ESM

Bash
Error [ERR_MODULE_NOT_FOUND]: Cannot find module './utils'

In CJS, Node resolves ./utils to ./utils.js automatically. In ESM, it does not. You must include the file extension explicitly — always.

JavaScript
// Works in CJS, breaks in ESM
import { helper } from './utils';
import { config } from './config/index';

// ESM requires explicit file extensions
import { helper } from './utils.js';
import { config } from './config/index.js';

Error 3: __dirname and __filename are not defined

Bash
ReferenceError: __dirname is not defined in ES module scope

These are CJS globals. They don't exist in ESM. Here's the modern replacement pattern:

JavaScript
// CJS-only globals — not available in ESM
const dir = __dirname;
const file = __filename;

// ESM equivalent using import.meta.url
import { fileURLToPath } from 'url';
import { dirname } from 'path';

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

// Or the newer import.meta.dirname (Node 21.2+)
const dir = import.meta.dirname;

Error 4: Named exports break from CJS packages

JavaScript
// This breaks with some CJS packages
import { readFileSync } from 'some-cjs-package';
// SyntaxError: The requested module does not provide an export named 'readFileSync'

// Use default import then destructure
import pkg from 'some-cjs-package';
const { readFileSync } = pkg;

CJS packages export a single module.exports object. When Node does the ESM interop, it wraps this as the default export. Named export destructuring only works if the package explicitly defines named exports in its exports field.

Error 5: Dual package hazard — the silent one

This one doesn't throw an error, which makes it worse. If a package ships both CJS and ESM (a dual package), and your app loads it via both code paths, you can end up with two separate instances of the module. Singletons break. State doesn't share. Caches are split.

JavaScript
// Two paths in your app load the same package differently:
// path A (ESM): import { store } from 'state-lib'  → instance #1
// path B (CJS): const { store } = require('state-lib')  → instance #2
// store in A and store in B are different objects — state never syncs

// Fix: consistency. Pick one module system and stick to it.

The Real Fix: Converting a Node.js Project to Pure ESM

If you're starting fresh or have the bandwidth to migrate, pure ESM is the right call in 2026. Here's the complete migration checklist:

JSON
{
  "name": "my-app",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "engines": {
    "node": ">=18"
  }
}
JavaScript
// Before: CJS entry point
const express = require('express');
const { join } = require('path');
const router = require('./routes');
module.exports = { startServer };

// After: ESM entry point
import express from 'express';
import { join } from 'path';
import router from './routes.js';  // .js extension required
export { startServer };
JSON
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true
  }
}

The NodeNext module setting in TypeScript is the key change most migration guides miss. Without it, TypeScript won't enforce the .js extensions in imports, and your compiled output will have missing extensions that break at runtime.

When You Can't Migrate: The Dynamic Import Workaround

If you're stuck in a CJS codebase and need to use an ESM-only package, dynamic import() is your escape hatch. It works inside CJS files and returns a Promise.

JavaScript
// In a CJS file — using an ESM-only package like chalk v5+ or node-fetch v3+
async function sendRequest(url) {
  const { default: fetch } = await import('node-fetch');
  const { default: chalk } = await import('chalk');

  const response = await fetch(url);
  console.log(chalk.green(`Status: ${response.status}`));
  return response.json();
}

// If you need it at module load time, use a top-level IIFE
(async () => {
  const { default: chalk } = await import('chalk');
  console.log(chalk.blue('Starting...'));
})();

GitHub Repository

The full repo for this article has six working examples: a broken CJS project hitting all five errors, ESM migration steps with diffs, a dual-package setup showing the hazard, and a TypeScript NodeNext config that compiles cleanly:

👉 https://github.com/Sandeep007-Stack/esm-vs-cjs

Each example has a broken/ and fixed/ folder. Clone it, run node broken/index.js, see the real error, then run node fixed/index.js and see it work.


The Takeaway

The ESM/CJS split isn't a bug you can patch with a Stack Overflow answer. It's a fundamental architectural difference in how the two systems load code. Once you understand the rules — CJS can't require() ESM, extensions are mandatory in ESM, __dirname doesn't exist — the errors stop being mysterious.

In 2026, the path forward is clear: pure ESM. Every major package is dropping CJS. Node 20+ has the interoperability. The tooling has caught up. Start new projects with "type": "module". Migrate existing ones file by file using the checklist above.

The broken import era is ending. It just requires knowing which side of the wall you're on.

Found this useful? Share it.

XLinkedInHN
SB

Sandeep Bansod

I'm a Front-End Developer located in India focused on making websites look great, work fast and perform well with a seamless user experience. Over the years I've worked across different areas of digital design, web development, email design, app UI/UX and development.

Related Articles

You might also enjoy these

Stay in the loop

Get articles on technology, health, and lifestyle delivered to your inbox.No spam — unsubscribe anytime.