ERR_REQUIRE_ESM: The Complete Guide to Why Your Import Broke
The error that shows up when a dependency goes ESM only. What it means, the five ways to fix it, and how to pick the right one.
The short answer
Error [ERR_REQUIRE_ESM]: require() of ES Module /node_modules/chalk/source/index.js
from /app/index.js not supported.
A CommonJS file is trying to require() a package that ships only ES modules. Five options, in rough order of preference:
- Upgrade Node to 22.12 or later, or 20.19 or later.
require(esm)now works for most modules and this alone often fixes it. - Convert your project to ESM with
"type": "module". Correct long term. - Use dynamic
await import(). Smallest change if only one call site is affected. - Pin the dependency to its last CommonJS version. Buys time, accrues debt.
- Bundle both formats with tsup or esbuild. Right answer if you are publishing a library.
Tested on Node 22.14 and npm 10.9. The require(esm) support described below landed in Node 22.12 and was backported to 20.19.
Why this happens at all
JavaScript has two module systems with fundamentally different loading semantics.
CommonJS resolves and executes synchronously. When you call require('./thing'), Node reads the file, runs it top to bottom, and returns module.exports, all before your next line runs.
ES modules are asynchronous by design. The specification defines three phases: parse the whole graph to discover imports, link the bindings, then evaluate. Top level await means evaluation itself can suspend.
You cannot express "wait for an async operation" inside a synchronous function call. That is the entire reason requiring an ES module was an error for years. Not stubbornness, an actual impedance mismatch.
What changed is that Node 22.12 shipped require(esm). When you require an ES module, Node now synchronously evaluates it, provided the graph contains no top level await. If it does, you get a different error, ERR_REQUIRE_ASYNC_MODULE, and you are back to needing a real fix. So the constraint did not disappear, it narrowed to the genuinely impossible case.
This is why upgrading Node is the first thing to try. A large share of packages that broke builds in 2023 simply work now.
Confirm the diagnosis
Two questions. Is the dependency really ESM only, and is your file really CommonJS?
Is the package ESM only?
cat node_modules/chalk/package.json | jq '{type, main, module, exports}'
{
"type": "module",
"main": null,
"exports": "./source/index.js"
}
"type": "module" with no CommonJS entry in exports means ESM only. If instead you see an exports map containing a require condition, the package is dual and something else is wrong, usually a bundler resolving the wrong condition.
Is your file CommonJS? It is if your nearest package.json lacks "type": "module" and the extension is .js, or if the extension is .cjs.
TypeScript adds a wrinkle. If your tsconfig has "module": "commonjs", your import statements compile down to require() calls. The source looks like ESM and the output is not. This is the single most common source of confusion with this error, because the fix has to happen in tsconfig.json rather than in the source file.
The fixes
1. Upgrade Node
node -v # need 22.12.0 or later, or 20.19.0 or later
Try this before anything else. If the package has no top level await anywhere in its import graph, require() now works and you can stop reading.
If you get ERR_REQUIRE_ASYNC_MODULE after upgrading, the package uses top level await and you need one of the options below.
One interop detail to budget for: require() of an ES module returns the module namespace object, not the default export.
const chalk = require('chalk'); // { default: [Function], Chalk: ... }
chalk('hi'); // TypeError: chalk is not a function
chalk.default('hi'); // works
Node adds a module.exports style shortcut when the ESM module has only a default export, but for mixed exports you need .default. This produces a confusing second error right after you fix the first one.
2. Convert your project to ESM
The correct destination. In package.json:
{ "type": "module" }
Then the migration work:
Add file extensions to relative imports. import './utils' becomes import './utils.js'. ESM does not do extension resolution. In TypeScript you write .js even though the file is .ts, which looks wrong and is correct.
Replace __dirname and __filename:
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
Or on Node 20.11 and later, just import.meta.dirname.
Replace require.resolve with import.meta.resolve.
JSON imports need an attribute: import pkg from './package.json' with { type: 'json' };
Jest needs --experimental-vm-modules, or move to Vitest which handles ESM natively.
Any remaining CommonJS files get renamed to .cjs.
For TypeScript the matching config is:
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2022"
}
}
nodenext is the setting that makes TypeScript model Node's actual dual module behaviour, including telling you when an import will not work at runtime. Anything else and TypeScript will happily compile code that throws.
3. Dynamic import
If exactly one place uses the offending package, this is a two line change:
// before
const chalk = require('chalk');
// after
const chalk = (await import('chalk')).default;
import() is asynchronous and therefore has no impedance mismatch. It works from CommonJS on every Node version.
The catch is that await needs an async context. At the top level of a CommonJS module you have to wrap it:
let chalk;
async function init() {
({ default: chalk } = await import('chalk'));
}
Which introduces an initialisation ordering problem. Fine for a CLI with a clear entry point, awkward inside a library.
There is a TypeScript trap here worth flagging. With "module": "commonjs", TypeScript compiles your await import() into Promise.resolve().then(() => require()), reintroducing exactly the require() you were trying to avoid. Set "module": "node16" or "nodenext". This trips up a lot of people who apply the fix correctly and see no change at all.
4. Pin the dependency
npm install chalk@4
Many popular packages have a final CommonJS release: chalk 4, node-fetch 2, nanoid 3, got 11, strip-ansi 6, execa 5, p-limit 3.
This works and I am not going to pretend otherwise. But you are now on a version that will not get security patches, and you will hit the same wall with the next package. Reasonable as a deliberate ninety day decision, bad as a permanent one. Leave a comment saying which one it is.
For transitive dependencies you do not control directly, npm overrides can force a version:
{ "overrides": { "strip-ansi": "6.0.1" } }
5. Bundle both formats
If you are publishing a library, build both. tsup does it in one line:
tsup src/index.ts --format cjs,esm --dts
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
Order matters inside the conditions object. types first, more specific conditions before default, because Node picks the first match.
The dual package hazard
Once you ship both formats you can end up with two copies of the same module in one process, one loaded through import and one through require. Separate module registries, separate state.
Invisible until it is not:
// instanceof fails across the boundary
err instanceof MyCustomError // false, different class object
// singletons are not singletons
registry.register('x') // registered on the CommonJS copy
registry.list() // called on the ESM copy, returns []
Mitigate by keeping all stateful logic in a CommonJS core and making the ESM entry a thin re export wrapper, so both formats share one instance. Or avoid module level state entirely, which is better design regardless.
Finding which file is at fault
The error names the file doing the require(), but in a deep dependency tree that file is often not yours.
node --trace-warnings index.js
NODE_DEBUG=module node index.js # very verbose
npm ls chalk # who depends on it
If the requiring file is inside node_modules, you cannot fix it directly. A dependency of yours is CommonJS and depends on an ESM only package. Your options are updating that intermediate dependency, or overriding the inner one to a CommonJS version.
Prevention
Start new projects with "type": "module". The ecosystem's direction is settled, and starting in CommonJS is choosing to migrate later.
Use "module": "nodenext" in TypeScript. It surfaces these problems at compile time instead of at runtime in production.
Run CI on the Node version you actually deploy. A require(esm) that works on Node 22 locally and fails on Node 18 in production is a bad afternoon.
Check exports before adding a dependency. npm view <pkg> exports takes two seconds.