Node.js ESM "ERR_MODULE_NOT_FOUND" on Extensionless Imports
Native ESM does not do CommonJS-style extension guessing. A relative import without a file extension that worked under CJS (or a bundler) fails under Node ESM with ERR_MODULE_NOT_FOUND.
What this error means
After switching to "type": "module" or running compiled ESM, imports like import { x } from "./util" fail with ERR_MODULE_NOT_FOUND, even though ./util.js exists. The same source ran fine under CommonJS or through a bundler.
node:internal/modules/esm/resolve:... Error [ERR_MODULE_NOT_FOUND]:
Cannot find module '/app/src/util' imported from /app/src/index.js
Did you mean to import './util.js'?Diagnose it: what is different about the runner?
A build that passes locally and fails on a runner differs in a small number of predictable ways. Check those before changing build configuration, because the build config is usually not the thing that changed.
- run: |
node --version && npm --version
echo "NODE_ENV=$NODE_ENV CI=$CI"
nproc && free -h && df -h /
ls -la node_modules/.bin | headCommon causes
Extensionless relative import under ESM
CommonJS and bundlers resolve ./util to ./util.js for you. Node’s native ESM resolver does not - it requires the full specifier including the .js (or .mjs) extension.
TypeScript output without rewritten extensions
TS source often imports ./util (no extension); when emitted as ESM, Node needs ./util.js. Without the right moduleResolution/rewrite, the emitted import is unresolvable.
How to fix it
Add explicit file extensions
Use the full extension in relative ESM imports.
// before
import { x } from './util';
// after
import { x } from './util.js';Configure TypeScript for Node ESM
Use a Node-aware module resolution so TS expects/keeps the .js extension in emitted imports.
// tsconfig.json
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext"
}
}The three that account for most of them
- Case sensitivity. Linux runners are case sensitive, macOS is not. An import with the wrong case resolves locally and fails in CI.
- Out of memory. Exit code 137 is a SIGKILL from the kernel, not a build error. Raise
--max-old-space-sizeor use a larger runner. - devDependencies pruned.
NODE_ENV=productionmakesnpm ciskip devDependencies, so the build tool itself goes missing. Set it after install, not before.
How to prevent it
- Always include file extensions in relative ESM imports.
- Use
nodenextmodule resolution for TypeScript targeting Node ESM. - Lint with a rule that enforces explicit extensions in ESM.