Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

Fixing ERR_MODULE_NOT_FOUND for Extensionless Imports

Code that imports ./utils without a file extension works in TypeScript, in bundlers and in CommonJS — and fails the moment Node.js runs it as an ES module. Node's ESM loader resolves relative specifiers exactly as written: no extension guessing, no index.js lookup. The result is ERR_MODULE_NOT_FOUND pointing at a file you can see in your editor. This guide explains the resolution rule, shows how to fix a codebase at scale, and configures TypeScript so the mistake cannot come back.

Exact symptoms and error messages

The error names the path Node.js tried, which is always the specifier resolved literally against the importing file:

node:internal/modules/esm/resolve:283
    throw new ERR_MODULE_NOT_FOUND(
          ^
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/dist/lib/utils' imported from /app/dist/index.js
Did you mean to import "./lib/utils.js"?
    at finalizeResolution (node:internal/modules/esm/resolve:283:11)
  code: 'ERR_MODULE_NOT_FOUND',
  url: 'file:///app/dist/lib/utils'

Directory imports fail with a different code:

Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import '/app/dist/components' is not supported resolving ES modules imported from /app/dist/index.js
Did you mean to import "./components/index.js"?

Library consumers see the same errors inside node_modules when a package was compiled with extensionless relative imports:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/node_modules/tiny-lib/dist/helpers' imported from /app/node_modules/tiny-lib/dist/index.js

That last variant is the most damaging: the package works in every bundler and in its own test suite, and breaks only for users running it directly in Node.js.

Root cause analysis

CommonJS require() has always tried a list of candidates for a relative path: the exact name, then .js, .json, .node, then index.js inside a directory. The ES module specification deliberately dropped this. ESM resolution follows URL semantics, so ./utils is a URL that points at a file literally called utils. Browsers behave the same way, which is why the rule exists: one resolution algorithm across runtimes, and no filesystem probing on every import. The wider differences between the two module systems are covered in ESM and CJS Interoperability.

Relative specifier resolution in CommonJS versus ESM Compares how require and import resolve extensionless paths, directory paths, explicit .js paths and .ts source paths. require() (CJS) import (ESM in Node) bundlers './utils' tries .js, .json, /index.js ERR_MODULE_NOT_FOU ND probes extensions './components' components/index.js ERR_UNSUPPORTED_DIR _IMPORT index lookup './utils.js' exact file exact file exact file './utils.ts' not loadable only with type stripping transpiles
ESM resolves exactly what you write — every probe that CommonJS performed must be spelled out in the specifier.

Why does the code work until it runs? Because the tools earlier in the chain do not enforce the rule. Bundlers such as Vite, webpack and esbuild resolve extensionless paths for convenience. TypeScript with moduleResolution: "bundler" or "node10" accepts them. Test runners that transform code often use their own resolvers. The first component that applies Node's real ESM algorithm is Node.js itself, at runtime — frequently only in production, after tsc has emitted JavaScript that keeps the specifiers exactly as written.

TypeScript never rewrites import paths during emit. If your source says import { x } from './utils', the emitted .js file says the same, and Node.js cannot load it.

Resolution and configuration patch

The fix has two halves: correct the specifiers, and switch the compiler to a mode that rejects extensionless imports so they cannot reappear.

Fixing extensionless imports in a TypeScript package Switch TypeScript to nodenext resolution, let the compiler report every bad import, rewrite them with .js extensions, and run the emitted output in Node.js. moduleResolutio n: nodenext compiler now applies Node's ESM rules tsc --noEmit TS2835 lists every bad specifier add .js extensions codemod or editor quick fix node dist/index.js runtime proof on emitted output
Let the compiler find every bad import, then keep the setting so it enforces the rule forever.
  1. Configure TypeScript to enforce Node's rules. For packages that run in Node.js (libraries, servers, CLIs), use nodenext:
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2022",
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true
  }
}

With this setting, every extensionless relative import in an ES module file becomes a compile error:

src/index.ts:1:23 - error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './lib/utils.js'?
  1. Write the extension of the emitted file, not the source file. In a .ts file you import ./lib/utils.js, even though the file on disk is utils.ts. TypeScript maps .js back to .ts during type-checking and leaves the specifier alone during emit, so the output is correct. Use .mjs for imports of .mts files and .cjs for .cts.

  2. Replace directory imports with explicit index files. import { Button } from './components' becomes ./components/index.js. Better still, import the concrete module (./components/button.js), which also helps tree-shaking.

  3. Fix a large codebase mechanically. Run tsc --noEmit and apply the suggested fix from each TS2835 diagnostic. Editors such as VS Code offer "Add all missing imports extensions" as a quick fix across a file. For hundreds of files, a codemod is faster — tools like ts2esm and fix-esm-import-path rewrite specifiers using the same resolution logic.

  4. Consider rewriteRelativeImportExtensions only when running .ts sources directly. TypeScript 5.7 added an option that rewrites ./utils.ts to ./utils.js during emit, for projects that import .ts paths so the same source also runs under Node's type stripping. It is useful for that workflow, but writing .js specifiers remains the portable default.

Bundled applications are different

If your code is only ever consumed by a bundler — a Vite or Next.js application — extensionless imports are harmless and moduleResolution: "bundler" is the correct setting. The rule matters for anything Node.js loads directly: libraries you publish, server code compiled with tsc, CLIs and scripts. A monorepo often needs both: bundler for application packages and nodenext for library packages, set per package through a shared base configuration as shown in Sharing a Base tsconfig Across Workspaces.

Worked example: a library that only broke for Node.js users

A small utility library compiled with tsc and moduleResolution: "bundler" ships to npm. Its tests pass under Vitest, the documentation site built with Vite works, and a React application installing it works. Then a user importing it from a plain Node.js script reports ERR_MODULE_NOT_FOUND ... dist/helpers.

The emitted dist/index.js contains export * from './helpers'. Vitest and Vite resolved that with their own extension probing; Node.js does not. The maintainer switches the library's tsconfig.json to nodenext, fixes forty TS2835 errors by adding .js, publishes a patch release, and adds a job to CI that installs the packed tarball and runs node --input-type=module -e "import('tiny-lib')". The job would have caught the bug before the first release — and it will catch the next one, because a test runner with a lenient resolver cannot prove Node.js compatibility.

Why tests passed but Node.js failed Vitest resolves the extensionless import with its own resolver, while Node.js loads the emitted file and rejects the same specifier. Vitest dist/index.js Node.js loader import './helpers' (probes .ts, .js) resolved via Vite resolver import './helpers' (exact URL) ERR_MODULE_NOT_FOUND
A lenient resolver in tests hides the bug; only a plain Node.js import of the emitted output proves the package works.

JavaScript projects without TypeScript

Plain JavaScript packages have no compiler to enforce the rule, so the checks move to lint and tests. Three settings together give the same protection that nodenext gives TypeScript users.

First, ESLint's import/extensions rule with ["error", "ignorePackages", { "js": "always" }] requires .js on every relative import while leaving bare package imports alone. Second, the n/file-extension-in-import rule from eslint-plugin-n performs the same check with awareness of Node.js resolution. Third, and most important, run your test suite with Node's built-in test runner (node --test) or with Vitest configured to run against built files, so imports are resolved by Node.js rather than by a transform pipeline.

Editors can help too. VS Code's javascript.preferences.importModuleSpecifierEnding setting set to js makes auto-imports write ./utils.js instead of ./utils, which stops new extensionless imports from being introduced in the first place. The same option exists for TypeScript as typescript.preferences.importModuleSpecifierEnding, and with nodenext resolution the editor picks the right ending automatically.

Reading the error when it comes from a dependency

When the failing path sits inside node_modules, the bug belongs to the package author, but you still need a way forward today. Confirm the diagnosis by opening the file named after "imported from" and looking at its import statements: extensionless relative specifiers in a file that Node.js treats as ESM confirm it. Then check whether a newer version of the package fixed it; this is one of the most commonly reported and quickly fixed issues in libraries that recently added ESM output.

If no fixed release exists, you have three stopgaps. Import the package's CommonJS build explicitly if it ships one, since require() probes extensions. Run your code through a bundler for that dependency, which resolves the imports at build time. Or apply a local patch with pnpm patch or patch-package that adds the missing .js extensions, and link the upstream issue in the patch file so the workaround is removed when the fix ships.

CLI validation and debug commands

# Type-check with Node's rules; every remaining TS2835 is a bug
npx tsc --noEmit -p tsconfig.json

# Find extensionless relative specifiers in emitted output
grep -rnE "from '\.{1,2}/[^']*[^.][^j][^s]'" dist/ | grep -v "\.js'" | head

# Load the built entry exactly as a consumer would
node --input-type=module -e "await import('./dist/index.js'); console.log('esm ok')"

# Resolve a single specifier and print the result
node --input-type=module -e "console.log(import.meta.resolve('./dist/lib/utils.js'))"

Prevention and CI/CD guardrails

  • Use nodenext for every package Node.js loads directly, and keep it in the shared base config so new packages inherit it.
  • Run the emitted output in CI. One node import of dist/index.js per entry point catches what lenient test resolvers hide.
  • Validate the packed tarball with Are the Types Wrong and a real install, as in Smoke-Testing a Tarball with npm pack.
  • Lint for it. The import/extensions ESLint rule with "always" for relative imports enforces explicit extensions in JavaScript projects without TypeScript.

Frequently Asked Questions

Why import ./utils.js when the file is utils.ts? Because the specifier must be correct for the emitted JavaScript that Node.js runs. TypeScript understands that ./utils.js refers to utils.ts during type-checking and never rewrites specifiers during emit.

Is there a Node.js flag to restore extension probing? Older Node.js versions had --experimental-specifier-resolution=node, but it was removed in Node.js 19. Custom loader hooks can emulate probing, but they slow every import and hide the real problem from consumers.

Does this affect bare package imports like 'lodash'? No. Bare specifiers resolve through node_modules and the package's exports or main field. The explicit-extension rule applies to relative and absolute paths only — though deep imports into a package without an exports map, such as lodash/fp, also need the extension in ESM.

Related

ESM and CJS Interoperability