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

typescript dev.to

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
  • 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Error 2: ERR_MODULE_NOT_FOUND with ESM

Error [ERR_MODULE_NOT_FOUND]: Cannot find module './utils'
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.jsonadd"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"}}
Enter fullscreen mode Exit fullscreen mode
// 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 };
Enter fullscreen mode Exit fullscreen mode
// tsconfig.json — TypeScript ESM config
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",      // critical: enables ESM-aware resolution
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true
  }
}
Enter fullscreen mode Exit fullscreen mode

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...'));
})();
Enter fullscreen mode Exit fullscreen mode

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

Read Full Tutorial open_in_new
arrow_back Back to Tutorials