The Scenario
It's 2 AM. Your deploy just failed, or your local build exploded after bumping a dependency. The stack trace looks like this:
Error [ERR_REQUIRE_ESM]: require() of ES Module /path/to/node_modules/node-fetch/src/index.js not supported.
/path/to/node_modules/node-fetch/src/index.js is an ES module file as it is a .js file whose nearest parent package.json contains "type": "module" which defines all .js files in that package as ES modules.
Instead either rename /path/to/node_modules/node-fetch/src/index.js to end in .cjs, change the requiring code to use import(), or remove "type": "module" from /path/to/node_modules/node-fetch/src/index.js.
at Object. (/your/project/src/api.js:3:18)
Your code didn't change โ only the package version did. Welcome to the CommonJS vs ESM compatibility wall.
What's Actually Happening
Node.js supports two module systems:
- CommonJS (CJS): uses
require()/module.exportsโ the classic Node.js way - ESM (ES Modules): uses
import/exportโ the modern standard
The problem: CJS cannot require() an ESM module. Full stop. Starting around 2021, popular packages like node-fetch v3, chalk v5, nanoid v4, got v12, and execa v6 dropped CJS support entirely and went ESM-only. The moment you upgrade one of these, any project still using require() hits a wall.
Identify the Root Cause First
Before picking a fix, confirm what module system your project uses:
cat package.json | grep '"type"'
- No
typefield or"type": "commonjs"โ your project is CJS "type": "module"โ your project is ESM
Then check the offending package:
cat node_modules/node-fetch/package.json | grep '"type"'
If it says "type": "module", that package is ESM-only โ you can't require() it. Now you know the enemy. Pick your fix below.
Quick Fix: Pin to the Last CJS Version
Need it working in the next 5 minutes? Pin the package to its last CommonJS-compatible version. No code changes required.
# node-fetch: last CJS version is v2
npm install node-fetch@2
# chalk: last CJS version is v4
npm install chalk@4
# nanoid: last CJS version is v3
npm install nanoid@3
# got: last CJS version is v11
npm install got@11
# execa: last CJS version is v5
npm install execa@5
Verify the fix:
node -e "const fetch = require('node-fetch'); console.log(typeof fetch);"
It should print function. You're unblocked. Ship it.
Permanent Fix Option 1: Use Dynamic import()
Want the latest package version without converting your whole project? Dynamic import() is your bridge. CJS files can call import() โ they just can't use the static import keyword at the top of the file.
// Before (broken)
const fetch = require('node-fetch');
// After (works in CJS files)
async function fetchData(url) {
const { default: fetch } = await import('node-fetch');
const res = await fetch(url);
return res.json();
}
Notice the { default: fetch } destructuring. When you access an ESM default export via dynamic import from CJS, it lands on the default property โ not directly as the value.
Quick test to confirm it works:
node -e "
async function test() {
const { default: fetch } = await import('node-fetch');
const res = await fetch('https://httpbin.org/get');
console.log(res.status);
}
test();
"
Permanent Fix Option 2: Convert Your Project to ESM
For a clean long-term solution, migrate the whole project to ESM. More work upfront โ but it eliminates the compatibility problem entirely.
Step 1: Update package.json
{
"type": "module"
}
Step 2: Replace require/module.exports with import/export
// Before (CJS)
const express = require('express');
const { readFileSync } = require('fs');
module.exports = { myFunction };
// After (ESM)
import express from 'express';
import { readFileSync } from 'fs';
export { myFunction };
Step 3: Fix __dirname and __filename (they don't exist in ESM)
// Add this at the top of files that need __dirname
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Step 4: Fix dynamic requires
// Before
const config = require(`./configs/${env}.js`);
// After
const { default: config } = await import(`./configs/${env}.js`);
Run the project and see what breaks:
node src/index.js
Expect a few more edge cases โ JSON imports, require.resolve, and the occasional older package with no ESM support. Fix them as they surface.
Permanent Fix Option 3: Use a Bundler or Transpiler
Manual migration too risky? Let a bundler handle the CJS/ESM conversion for you.
# esbuild โ the fastest option
npm install -D esbuild
npx esbuild src/index.js --bundle --platform=node --outfile=dist/index.cjs
# tsx โ great for TypeScript projects
npm install -D tsx
npx tsx src/index.ts
TypeScript users can stay on CJS output while writing ESM-style imports:
// tsconfig.json
{
"compilerOptions": {
"module": "CommonJS",
"esModuleInterop": true
}
}
Special Case: Running in Jest
Jest defaults to CJS mode, so ESM packages will break your tests too. Two options:
# Option 1: enable ESM support in Jest
NODE_OPTIONS='--experimental-vm-modules' npx jest
# Option 2: tell Jest to transform the ESM package
module.exports = {
transformIgnorePatterns: [
'node_modules/(?!(node-fetch|chalk|nanoid)/)',
],
};
Option 1 is simpler to set up. Option 2 gives you finer control when multiple ESM-only packages are involved.
Verification
Run these three checks after any fix:
# 1. Run your entry point directly
node src/index.js
# 2. Run your test suite
npm test
# 3. Spot-check the specific import
node -e "import('node-fetch').then(m => console.log('OK:', typeof m.default))"
No ERR_REQUIRE_ESM anywhere in the output? Done.
Which Fix to Choose
- Need it fixed in 5 minutes: pin to last CJS version
- Want latest package, minimal refactor: use dynamic
import() - Greenfield or willing to refactor: convert project to ESM
- TypeScript project: set
"module": "CommonJS"in tsconfig +esModuleInterop: true

