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.
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.
- 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'?
-
Write the extension of the emitted file, not the source file. In a
.tsfile you import./lib/utils.js, even though the file on disk isutils.ts. TypeScript maps.jsback to.tsduring type-checking and leaves the specifier alone during emit, so the output is correct. Use.mjsfor imports of.mtsfiles and.cjsfor.cts. -
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. -
Fix a large codebase mechanically. Run
tsc --noEmitand 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 likets2esmandfix-esm-import-pathrewrite specifiers using the same resolution logic. -
Consider
rewriteRelativeImportExtensionsonly when running.tssources directly. TypeScript 5.7 added an option that rewrites./utils.tsto./utils.jsduring emit, for projects that import.tspaths so the same source also runs under Node's type stripping. It is useful for that workflow, but writing.jsspecifiers 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.
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
nodenextfor 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
nodeimport ofdist/index.jsper 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/extensionsESLint 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 explains the two module systems and how they meet.
- Fixing 'Cannot use import statement outside a module' covers the error you hit before this one, when a file is not treated as ESM at all.
- Fixing ERR_UNKNOWN_FILE_EXTENSION for .ts Files handles running TypeScript sources directly.
- Fixing Types Not Found Under node16 Module Resolution addresses the type-level side of the same resolver switch.