If you see Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported, a CommonJS file is trying to require() a package that now ships only ES modules. As of late 2026 the quickest fix is often upgrading Node (20.19+ or 22.12+ can require() ES modules), but the durable fix is making your project's module format consistent: either go all-ESM, or pin the dependency and bundle.
This post covers the error variants you will actually hit, why each happens, and which TypeScript runner (tsx, ts-node, tsup) to use so you stop fighting it.
What does ERR_REQUIRE_ESM actually mean?
Node has two module systems. CommonJS uses require() and module.exports. ES modules use import/export. For years, CommonJS could not load ESM synchronously, because ESM loading is asynchronous by design. The classic failure looks like this:
Error [ERR_REQUIRE_ESM]: require() of ES Module /app/node_modules/chalk/source/index.js
from /app/dist/index.js not supported.
Instead change the require of index.js in /app/dist/index.js to a dynamic import() which is available in all CommonJS modules.
This usually appears after a routine npm update. A maintainer of a dependency (chalk, node-fetch and nanoid are well-known past examples) released a major version that dropped CommonJS, and your lockfile picked it up. Nothing in your code changed, which is why it feels random.
The trap: TypeScript compiled with "module": "commonjs" rewrites your import chalk from 'chalk' into a require('chalk'). Your source looks like ESM, your output is CommonJS, and the crash happens at runtime.
Takeaway: the error is about the format of the file doing the requiring, not the syntax in your source.
Does newer Node make this go away?
Partly. As of late 2026, require() of a synchronous ES module works without flags on Node 22.12+ and 20.19+ (earlier versions needed --experimental-require-module). Check with node -v before debugging anything else; in my experience a stale Node in a Docker base image is a common reason the error persists on a server while it works on a laptop.
It has real limits:
- A module that uses top-level
awaitstill fails, withERR_REQUIRE_ASYNC_MODULE. -
require()returns the module namespace object, so a default export arrives as.default:
// CommonJS file requiring an ESM-only package on a supporting Node version
const mod = require('some-esm-only-package');
const fn = mod.default ?? mod; // default export lives on .default
- Bundlers and some older tooling (Jest in its default CommonJS mode, for example) have their own resolution and may still choke.
Takeaway: upgrading Node fixes the common case, but top-level await and default-export shape are the two places it still bites.
How do you fix it permanently?
You have three honest options, in order of how much I recommend them for new code.
1. Go all-ESM. Set "type": "module" in package.json, and in tsconfig.json use a Node-aware setting:
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"outDir": "dist",
"strict": true
}
}
Relative imports then need explicit extensions, and this is the part that trips people up. Write import { db } from './db.js' even though the file on disk is db.ts. TypeScript maps it, and Node needs the .js at runtime.
2. Stay CommonJS and use dynamic import for the one offender. This works in any CommonJS file on any supported Node:
async function main() {
const { default: chalk } = await import('chalk');
console.log(chalk.green('ok'));
}
main();
One catch: if TypeScript is compiling to CommonJS, it will rewrite import() into require() and bring the error back. With module: "NodeNext" in a CommonJS package it preserves dynamic import(); with the older module: "commonjs" it does not.
3. Pin the last CommonJS major. Fast, and a legitimate stopgap, but you stop receiving security fixes for that dependency. Treat it as a ticket, not a solution.
Takeaway: pick one module format per package and make TypeScript's module setting match it; most of these errors are a mismatch between the two.
"Unknown file extension .ts": which TypeScript runner should you use?
The second error people search for right after the first is:
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /app/src/index.ts
It means Node's ESM loader was handed a .ts file with no loader that understands TypeScript. This is the classic symptom of running ts-node against an ESM project without the right loader flags. Your options:
| Tool | What it does | Type checks? | Best for | Real drawback |
|---|---|---|---|---|
tsx |
Runs TS/ESM/CJS files directly, using esbuild under the hood | No | Scripts, dev servers, mixed CJS/ESM repos | Won't catch type errors; run tsc --noEmit separately |
ts-node |
Compiles with the TypeScript compiler at runtime | Yes (unless --transpile-only) |
Existing projects already configured for it | ESM support needs careful config and tends to produce exactly the error above |
tsup |
Bundles TS into dist/ (CJS, ESM or both) with .d.ts output |
Declarations only, via a flag | Publishing libraries, shipping a single built artifact | A build step; not a runtime launcher |
| Node native type stripping | Newer Node versions can run .ts files directly |
No | Simple scripts with no enums or path aliases | Supports only erasable TypeScript syntax; check your Node version |
A working dev setup I would start from today:
{
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"typecheck": "tsc --noEmit",
"build": "tsup src/index.ts --format esm --target node20 --clean",
"start": "node dist/index.js"
}
}
If you want a runner that stops you thinking about CommonJS versus ESM during development, tsx is the one that handles both without configuration. It will not tell you about type errors, so the typecheck script in CI is not optional.
If you are publishing a library that consumers on both module systems will import, tsup is the tool that emits both formats plus type declarations from one command. You still need to get the exports map in package.json right, and tsup does not do that for you.
Takeaway: use tsx to run, tsc --noEmit to check, and tsup to ship; ts-node is only worth keeping if it already works in your repo.
FAQ
Why does ERR_REQUIRE_ESM only happen after npm update?
A dependency released a major version that is ESM-only, and your version range allowed it. Check the package's changelog and "type"/"exports" fields in its package.json.
Can I use require() on an ES module?
Yes on Node 22.12+ and 20.19+, as long as the module has no top-level await. Older versions throw ERR_REQUIRE_ESM, and you need dynamic import() instead.
Why do I need .js extensions in TypeScript imports?
Node's ESM resolver does not guess extensions, and TypeScript does not rewrite your import paths. You write the extension the file will have after compilation, which is .js.
Bottom line
For a new project, go all-ESM with "type": "module", NodeNext module settings, tsx during development, and tsup if you publish. For an existing CommonJS codebase hit by one ESM-only dependency, upgrade Node first, then use dynamic import() for that package, and pin the old major only as a short-lived stopgap. Whichever way you go, keep tsc --noEmit in CI, because the fast runners all skip type checking.
Top comments (0)