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
importfrom CJS packages — Node does the interop for you - 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:
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' = ESM
The 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');
// Note: this makes your function async
// 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
// Check the package's CHANGELOG for when they dropped CJS
npm install some-package@2 // many packages kept CJS through v2
Error 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 extension explicitly.
// 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';
Yes, even if the file is TypeScript. When you compile TS to ESM, the import paths need .js extensions in your source files — TypeScript will resolve them correctly during compilation.
Error 3: __dirname and __filename are not defined
ReferenceError: __dirname is not defined in ES module scope
These are CJS globals. They don't exist in ESM. Here's the replacement:
// CJS-only globals
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 using 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.
// If somehow 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
The fix is consistency: pick one module system for your entire app 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:
//package.json—add"type":"module"andupdateexports{"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'; // note: .js extension required
export { startServer };
// tsconfig.json — TypeScript ESM config
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext", // critical: enables ESM-aware resolution
"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 in CJS files and returns a Promise.
// In a CJS file (.js without "type":"module", or .cjs)
// 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...'));
})();
Cache the import result if you're calling it in hot paths — dynamic import() is cached after the first call, but the async overhead of the call itself still adds up.
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.git
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+ backported ESM-from-CJS interop. The tooling has caught up. If you have a new project, start with "type": "module". If you have an existing one, use the checklist above and migrate file by file.
The broken import era is ending. It just requires knowing which side of the wall you're on.
If you found this useful, I write about TypeScript, Node.js, Security and DevOps at stackdevlife.com — drop a follow if you'd like more!
Originally published at stackdevlife.com