Skip to content

Node.js ERR_MODULE_NOT_FOUND: Causes and Fixes

Node throws Error [ERR_MODULE_NOT_FOUND] when an ESM import can't be resolved — usually a missing file extension or directory index. Here's the exact fix.

nodejs nodejs-errors esm commonjs JavaScript err-module-not-found v8 nodejs-24
Bharath G
Reading Progress

On This Page

If you've moved a package to "type": "module" and started seeing Error [ERR_MODULE_NOT_FOUND] on imports that worked fine yesterday under require(), you've hit the single biggest behavioral gap between Node's two module systems: the ESM resolver does not guess file extensions, and it does not fall back to index.js for directory imports. CommonJS does both. Nobody reads the spec before they hit this in a stack trace.

1. The Error

The most common shape — importing a relative specifier without its extension:

node:internal/modules/run_main:123
    triggerUncaughtException(
    ^

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/utils' imported from /app/index.js
Did you mean to import "./utils.js"?
    at finalizeResolution (node:internal/modules/esm/resolve:275:11)
    at moduleResolve (node:internal/modules/esm/resolve:861:10)
    at defaultResolve (node:internal/modules/esm/resolve:985:11)
    at #cachedDefaultResolve (node:internal/modules/esm/loader:731:20)
    at ModuleLoader.resolve (node:internal/modules/esm/loader:708:38)
    at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:310:38)
    at ModuleJob._link (node:internal/modules/esm/module_job:182:49) {
  code: 'ERR_MODULE_NOT_FOUND',
  url: 'file:///app/utils'
}

Node.js v22.22.2

Its close sibling, importing a directory instead of a file inside it:

node:internal/modules/run_main:123
    triggerUncaughtException(
    ^

Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import '/app/lib' is not supported resolving ES modules imported from /app/main.js
Did you mean to import "./lib/index.js"?
    at finalizeResolution (node:internal/modules/esm/resolve:263:11)
    at moduleResolve (node:internal/modules/esm/resolve:861:10)
    at defaultResolve (node:internal/modules/esm/resolve:985:11)
    at #cachedDefaultResolve (node:internal/modules/esm/loader:731:20)
    at ModuleLoader.resolve (node:internal/modules/esm/loader:708:38)
    at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:310:38)
    at ModuleJob._link (node:internal/modules/esm/module_job:182:49) {
  code: 'ERR_UNSUPPORTED_DIR_IMPORT',
  url: 'file:///app/lib'
}

Node.js v22.22.2

And the genuine "the file really isn't there" case, which drops the "Did you mean" hint because there's nothing sensible to suggest:

node:internal/modules/esm/resolve:275
    throw new ERR_MODULE_NOT_FOUND(
          ^

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/utils.js' imported from /app/index.js
    at finalizeResolution (node:internal/modules/esm/resolve:275:11)
    at moduleResolve (node:internal/modules/esm/resolve:861:10)
    at defaultResolve (node:internal/modules/esm/resolve:985:11)
    at #cachedDefaultResolve (node:internal/modules/esm/loader:731:20)
    at ModuleLoader.resolve (node:internal/modules/esm/loader:708:38)
    at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:310:38)
    at ModuleJob._link (node:internal/modules/esm/module_job:182:49) {
  code: 'ERR_MODULE_NOT_FOUND',
  url: 'file:///app/utils.js'
}

Node.js v22.22.2

All three traces above are real output, captured by running the reproductions below on Node 20.20.2, 21.7.3, and 22.22.2, then cross-checked against the lib/internal/modules/esm/resolve.js source at the v24.9.0 and v26.0.0 tags in the nodejs/node repository. The throw sites (finalizeResolution), the error shape, and the "Did you mean to import" hint (added by decorateErrorWithCommonJSHints in defaultResolve) are unchanged across all five lines — only the internal line numbers in the stack shift release to release as the loader file is edited. Don't rely on those line numbers to identify a Node version; rely on the Node.js vX.Y.Z footer on the last line.

This is distinct from MODULE_NOT_FOUND (no ERR_ prefix, thrown by the CommonJS require() loader) — that's a different resolver with different rules, covered in Node.js Error: Cannot Find Module — Causes and Fixes. If you're getting ERR_MODULE_NOT_FOUND you're already inside the ESM loader, which means either your file has "type": "module" in the nearest package.json, or the file itself ends in .mjs.

2. How to Reproduce It (step-by-step)

Case A — missing extension

mkdir esm-noext && cd esm-noext
node -v   # any of 20.x / 21.x / 22.x / 24.x / 26.x reproduces this identically

package.json:

{
  "name": "esm-noext",
  "version": "1.0.0",
  "type": "module"
}

utils.js:

export function helper() {
  return 'hi';
}

index.js:

import { helper } from './utils';
console.log(helper());
node index.js

That reproduces the first trace above verbatim (paths aside). The file utils.js exists on disk — the only thing wrong is the missing .js in the specifier. Under CommonJS, require('./utils') from the same directory resolves fine because the CJS loader tries ./utils, then ./utils.js, ./utils.json, ./utils.node, then ./utils/index.js in turn. The ESM loader tries none of that.

Case B — directory import

package.json (same "type": "module"), lib/index.js:

export const x = 1;

main.js:

import { x } from './lib';
console.log(x);
node main.js

Reproduces the ERR_UNSUPPORTED_DIR_IMPORT trace above. CommonJS's require('./lib') would read lib/package.json's main field or fall back to lib/index.js automatically; ESM does neither for relative specifiers.

Case C — genuinely missing file

Same as Case A but delete utils.js entirely before running. You get the third trace — no hint, because decorateErrorWithCommonJSHints only fires a suggestion when it can find a real file that the CJS algorithm would have resolved to.

Useful flags while debugging

NODE_DEBUG=esm node index.js       # traces every step the ESM loader takes
node --trace-warnings index.js     # if the failure is preceded by a related warning
node --enable-source-maps index.js # if the specifier is emitted by a TS/bundler build step

One dead end worth knowing about: --experimental-specifier-resolution=node, which used to make the ESM loader guess extensions like CJS does. It was removed in Node 19 (nodejs/node#44859) and has no effect on any currently supported line — passing it silently does nothing, it does not error and it does not fix the resolution.

3. Version Behaviour Matrix (Node.js 20 / 22 / 24 / 26)

This error is version-neutral: the "no extension guessing, no directory index fallback" rule has been part of the ESM resolution algorithm since Node first shipped stable ESM support, and nothing in the 20 → 26 range changes the throw condition, the error code, or the message format. What's worth tracking instead is support status and the escape hatches available on each line:

VersionStatus (as of Sep 2026)BehaviorEscape hatches available
20 (Iron)EOL — reached end-of-life 2026-04-30Identical resolver behavior, "Did you mean" hint presentexports map, explicit extensions
22 (Jod)Maintenance LTS (EOL 2027-04-30)IdenticalSame, plus the module-sync exports condition for dual packages
24 (Krypton)Active LTS (EOL 2028-04-30)Identical (confirmed against resolve.js at the v24.9.0 tag)Same; stable require(esm) makes some ESM/CJS interop errors moot but doesn't change this resolver rule
26Current (released 2026-04-22)Identical (confirmed against resolve.js at the v26.0.0 tag)Same

There's no DEP#### deprecation ID attached to this behavior — it was never deprecated, it's how the ESM resolver was designed from the start, deliberately stricter than CJS so that import specifiers stay valid as URLs. The only thing actually scheduled to change is Node's release cadence itself: starting with Node.js 27, Anthropic — sorry, Node.js — moves to one major release per year, version numbers aligned to the calendar year, and every line eventually becoming LTS, with an Alpha channel (27.0.0-alpha.x) available from October 2026 for early testing. None of that alters how import resolves a bare specifier.

4. Why It Happens — Surface Level

You wrote (or a tool generated) an import specifier that omits the file extension, or that points at a directory instead of a file. Node's ESM loader takes import specifiers literally: ./utils means a file literally named utils with no extension, not "some file named utils, figure out the extension." If that literal path doesn't exist, or it resolves to a directory instead of a file, resolution fails before your module ever executes.

This bites hardest in two situations: converting a CommonJS codebase to "type": "module" (every relative require() that omitted an extension is now broken), and writing TypeScript source where tsc compiles .ts to .js but developers instinctively import without an extension the way they always have in CJS-flavored TS setups.

5. Why It Happens — Under the Hood

Node has two independent module resolvers living in the same binary. The CommonJS loader (lib/internal/modules/cjs/loader.js) implements the algorithm documented as LOAD_AS_FILE / LOAD_AS_DIRECTORY in the CommonJS modules doc: try the exact path, then path.js, path.json, path.node, then treat the path as a directory and look for a package.json main field or index.js. This is a filesystem-probing algorithm — it does synchronous stat() calls to find out what actually exists.

The ESM loader (lib/internal/modules/esm/resolve.js) implements the ECMAScript Modules resolution algorithm, which is specified in terms of URL resolution, not filesystem probing. import './utils' resolves the specifier ./utils against the importing module's URL using standard URL-resolution rules, producing file:///app/utils — and that's the URL Node then checks on disk. There is no algorithmic step that says "if this URL doesn't correspond to a file, try appending .js." The finalizeResolution function does exactly one filesystem check: does this exact path exist, and is it a file? If stats === 1 (a directory), ERR_UNSUPPORTED_DIR_IMPORT is thrown; if stats says "not a file" for any other reason, ERR_MODULE_NOT_FOUND is thrown. Both error constructors are then caught one layer up, in defaultResolve, purely so decorateErrorWithCommonJSHints can re-run the CommonJS algorithm against the same specifier — not to actually resolve it, just to build a "Did you mean" suggestion string for you. That's a debugging courtesy bolted onto an otherwise strict algorithm, not a fallback path.

Why design it this way at all? Two reasons that show up throughout the ESM loader: import specifiers have to behave like URLs so that bare specifiers (import 'lodash'), relative specifiers, and absolute file:// URLs can all go through one resolution path uniformly, including across custom loader hooks (resolve/load) that see the same URL-shaped inputs; and extension-guessing is genuinely ambiguous once you allow package.json exports maps, .mjs/.cjs disambiguation, and loader hooks that can rewrite specifiers arbitrarily — silently trying five different extensions would make resolution unpredictable and slow across a large dependency graph. CommonJS predates all of that and never had to solve it.

You can watch the resolver work step by step with NODE_DEBUG=esm node index.js — it logs Translating StandardModule … and ModuleLoader.resolve entries for every specifier in the graph, which is the fastest way to see exactly which specifier failed when the failure is buried inside a dependency rather than your own code.

6. The Fix

Quick fix — add the extension:

- import { helper } from './utils';
+ import { helper } from './utils.js';

Note that you write .js, not .ts, even when the source file is utils.ts — Node runs compiled output, and the specifier has to match what's on disk at runtime, not your source layout. This is exactly why "moduleResolution": "nodenext" in tsconfig.json makes tsc require the .js extension in your .ts source: it's forcing your source to already look like valid ESM-resolvable output.

Directory imports — point at the real entry file:

- import { x } from './lib';
+ import { x } from './lib/index.js';

Or better, give lib/package.json an exports field so consumers get a stable public entry point instead of reaching into index.js directly:

{
  "name": "lib",
  "type": "module",
  "exports": "./index.js"
}

If you're mid-migration from CJS and have dozens of these: codemod them rather than fixing by hand. eslint-plugin-import's import/extensions rule (set to "always" for js/mjs) will flag every offending specifier project-wide, and most editors' "organize imports" won't add the extension for you — you generally need the ESLint autofix or a one-time script pass.

7. Best Practices & The Better Design

The fundamental fix is to stop treating import specifiers as filesystem paths you'll figure out later, and start writing them as the literal, resolvable URLs they are. Concretely:

// lib/index.js — the package's own public surface
export function helper() {
  return 'hi';
}
{
  "name": "esm-noext",
  "version": "1.0.0",
  "type": "module",
  "exports": "./lib/index.js"
}
// index.js — always explicit, always resolvable
import { helper } from './lib/index.js';
console.log(helper());

Pair that with import.meta.dirname and import.meta.filename (stable since Node 22.x, no more fileURLToPath(import.meta.url) boilerplate) whenever you need a filesystem path derived from the current module's location, rather than process.cwd(), which changes depending on where the process was launched from and has nothing to do with where your module lives on disk.

If you're publishing a package, declare exports explicitly instead of letting consumers reach into arbitrary internal files — it turns "did I forget the extension" into a problem your package's public API can't even expose, because everything outside the declared export map raises ERR_PACKAGE_PATH_NOT_EXPORTED (a separate, related error worth its own deep dive) rather than an inconsistent partial resolution.

8. How to Prevent It Long-Term

Turn this into a lint-time failure instead of a runtime one. eslint-plugin-import's import/extensions rule (["error", "always", { js: "always", mjs: "always" }]) and eslint-plugin-n's n/no-missing-import both catch missing or wrong extensions before code ships — wire either into your pre-commit hook or CI lint step, not just your editor's on-save formatting. If you're on TypeScript, set "moduleResolution": "nodenext" and "module": "nodenext" in tsconfig.json: tsc --noEmit will then refuse to compile a relative import that's missing its .js extension, catching the whole class of bug before Node ever runs the file.

For CI, run your test suite against the same module resolution mode you ship in production — don't let a bundler's or test runner's more lenient resolver (Jest's default CJS-style resolution, Vite's dev-server magic) mask an error that only surfaces once you run node dist/index.js for real. A CI matrix across the currently supported LTS lines (22 and 24) plus current (26) catches drift early; add a 27.0.0-alpha job once that channel opens in October 2026 if you want visibility before the next major lands. Finally, if you generate import specifiers programmatically (codegen, monorepo path-mapping tools, barrel-file generators), add a smoke test that actually runs node against the generated output — a passing tsc --noEmit doesn't guarantee the emitted JavaScript resolves correctly at runtime.

This error connects directly to a few others worth knowing as a set: ERR_PACKAGE_PATH_NOT_EXPORTED is what you get instead of ERR_MODULE_NOT_FOUND once a package declares an exports map and you reach outside it; ERR_REQUIRE_ESM (mostly moot now that require(esm) is stable in 22.12+/24+) is the mirror-image failure when CJS code tries to pull in an ESM-only dependency; and the plain MODULE_NOT_FOUND (no ERR_ prefix) is the CommonJS-side version of "can't find this file," with entirely different resolution rules, covered separately.

9. Key Takeaways / Learnings

  • ERR_MODULE_NOT_FOUND and ERR_UNSUPPORTED_DIR_IMPORT come from the ESM loader, which resolves specifiers as URLs — it never guesses extensions and never falls back to a directory's index.js, unlike require().
  • The "Did you mean to import…?" hint is a courtesy re-run of the CommonJS algorithm purely for suggestion purposes; it disappears when no CJS-style match exists, which usually means the file is genuinely missing.
  • This behavior is identical across Node 20, 22, 24, and 26 — there's no version-specific fix, only lint/type-check-time prevention.
  • Always write the runtime extension (.js), not the source extension (.ts), in relative import specifiers — "moduleResolution": "nodenext" in TypeScript enforces this at compile time.
  • --experimental-specifier-resolution=node is gone (removed in Node 19) — don't reach for it; fix the specifiers or declare an exports map instead.
nodejsnodejs-errorsesmcommonjsJavaScripterr-module-not-foundv8nodejs-24

From aspiring developer to blogger, I test learning platforms, simplify programming syntax, and share resources that work. Helping you code smarter as I grow myself. New or experienced, you're welcome here.

Comments