Skip to content
Latchkey

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 output
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.

.github/workflows/ci.yml
- run: |
    node --version && npm --version
    echo "NODE_ENV=$NODE_ENV  CI=$CI"
    nproc && free -h && df -h /
    ls -la node_modules/.bin | head

Common 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.

index.mjs
// 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
// 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-size or use a larger runner.
  • devDependencies pruned. NODE_ENV=production makes npm ci skip 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 nodenext module resolution for TypeScript targeting Node ESM.
  • Lint with a rule that enforces explicit extensions in ESM.

Frequently asked questions

What causes Node.js ESM "ERR_MODULE_NOT_FOUND" on extensionless imports?
There are 2 common causes: extensionless relative import under esm and typescript output without rewritten extensions. CommonJS and bundlers resolve ./util to ./util.js for you.
How do I fix Node.js ESM "ERR_MODULE_NOT_FOUND" on extensionless imports?
There are 2 fixes depending on which cause you have: add explicit file extensions and configure typescript for node esm. Work through them in order, since the first is the most common.
What does Node.js ESM "ERR_MODULE_NOT_FOUND" on extensionless imports actually mean?
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.
How do I stop Node.js ESM "ERR_MODULE_NOT_FOUND" on extensionless imports happening again?
Always include file extensions in relative ESM imports. The prevention section lists 3 changes that keep it from recurring.

Related guides

References

Not every red build is your code. Latchkey repairs the ones that are not, on the runner. Start free → 30-day trial · No credit card