
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.
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:
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.
# 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' = ESMThe Five Errors You'll Actually Hit
Error 1: ERR_REQUIRE_ESM
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:
// 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@2Error 2: ERR_MODULE_NOT_FOUND with ESM
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.
// 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
ReferenceError: __dirname is not defined in ES module scopeThese are CJS globals. They don't exist in ESM. Here's the modern replacement pattern:
// 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
// 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.
// 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:
{
"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"
}
}// 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 };{
"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.
// 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.
Related Articles
You might also enjoy these
How I Fixed a Node.js API That Was Taking 15 Minutes to Return 8,000 Records
A slow MongoDB + Node.js API was taking 15 minutes to return 8,000 records. Here's the exact process I used to diagnose it — missing indexes, in-memory aggregation, no pagination — and bring it down to 15 seconds.
How to Create and Publish Your First npm Package (and Use It in Angular)
This guide explains how to convert a TypeScript utility into a reusable npm package and use it in an Angular app. It covers project setup, configuration, publishing, and how to install, import, and run it in Angular, including common issues and solutions.
Stay in the loop
Get articles on technology, health, and lifestyle delivered to your inbox.
No spam — unsubscribe anytime.
