TypeScript

Fix ERR_MODULE_NOT_FOUND: relative import missing .js

Why an Express app with "type": "module" cannot find ./config/app, why the flags from older answers now do nothing, and how TypeScript catches it at compile time.

Mahdi Ben RhoumaMahdi Ben Rhouma
8 min read

TL;DR#

An ES module in Node.js resolves a relative import exactly as written. import app from './config/app' looks for a file literally named app with no extension, does not find one, and throws ERR_MODULE_NOT_FOUND. require() tries .js for you; import never does. The fix is to write the extension: import app from './config/app.js'. The --experimental-specifier-resolution=node flag from older answers was removed in Node.js 19 and does nothing today. In TypeScript compiled for Node, write the .js name too, and set "module": "nodenext" so tsc reports the missing extension as TS2835 before Node does.

The error#

The Stack Overflow question sets "type": "module" in package.json and has server.js import its Express app with import app from './config/app';. Adding .js makes it work. Removing it brings this back. Here is the same code on Node.js 24.21.0 in the official node:24-alpine image (stack trimmed):

text
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/config/app' imported from /app/server.js
Did you mean to import "./config/app.js"?
    at finalizeResolution (node:internal/modules/esm/resolve:272:11)
    at moduleResolve (node:internal/modules/esm/resolve:879:10)
    at defaultResolve (node:internal/modules/esm/resolve:1006:11)
  code: 'ERR_MODULE_NOT_FOUND',
  url: 'file:///app/config/app'
Node.js v24.21.0

The question dates from April 2020, when Node still printed "The ESM module loader is experimental" before the error. The core message has not changed since. The visible addition on current releases is the Did you mean to import line, and it already names the fix.

Why import will not guess the extension#

The Node.js ES modules documentation states the rule in its "Mandatory file extensions" section: "A file extension must be provided when using the import keyword to resolve relative or absolute specifiers." The same section says this matches how import behaves in browsers. A relative specifier is turned into a URL (file:///app/config/app) and loaded as written. No list of extensions gets tried, so a file called app.js does not match a URL ending in app.

require() works differently. The CommonJS modules documentation says that if the exact filename is not found, Node tries it with .js, .json and finally .node added, and that is what the Express examples written before ES modules relied on. With "type": "module", every .js file in the package is an ES module, so every relative import in it follows the strict rule.

The hint line comes from that older resolver. In lib/internal/modules/esm/resolve.js, when resolution fails with ERR_MODULE_NOT_FOUND, Node runs the CommonJS resolver on the same specifier and adds the result as "Did you mean to import …?". It is a suggestion printed after the failure, not a fallback. The import still fails. On Node.js 18.20.8 the same code produced a wrong path in the hint, so on old releases treat it as a clue rather than something to paste:

text
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/config/app' imported from /app/server.js
Did you mean to import ../config/app.js?

The file is ./config/app.js, relative to server.js. Node.js 24.21.0 printed the correct "./config/app.js".

Check your own import specifier#

Paste your full error into the explainer below, optionally with your package.json, the extension of the failing file and your Node.js version. It names the cause and lists the fixes, least invasive first, each with its source. It runs in your browser, and nothing is sent.

Runs entirely in your browser — nothing you paste is sent anywhere. Tokens and passwords that appear in paths or URLs are masked in the output.

Optional: package.json, file extension, Node.js version (sharper advice)
Try:

Fix#

  1. Add the extension to the failing import. In the question's server.js, only the second import line changes:
js
import http from 'http';
import app from './config/app.js';
  1. Find the other extensionless relative imports before Node finds them one at a time. Each failing import stops the process at the first miss, so fixing them one by one costs a restart each. This GNU grep lists static import … from and export … from lines in .js files whose relative specifier does not end in .js, .mjs, .cjs or .json, outside node_modules. It does not catch dynamic import('./x') calls, so search for those separately:
bash
grep -rnE "(from|import) +['\"]\.{1,2}/[^'\"]*['\"]" --include='*.js' --exclude-dir=node_modules . \
  | grep -vE "\.(js|mjs|cjs|json)['\"]"

On the question's project it finds exactly the one line:

text
./server.js:2:import app from './config/app';
  1. Leave package imports alone. import express from 'express' is a bare specifier, resolved through node_modules and the package's package.json. The extension rule only covers relative and absolute paths.

  2. Keep the start script as it is. nodemon server.js only restarts the process. It has no part in resolving imports, so neither the error nor the fix depends on it.

Verify the fix#

Start the app with the one changed import. The reproduction's server closes itself once it is listening, so on Node.js 24.21.0 it prints its listening line and exits cleanly:

text
v24.21.0
listening on 3000
exit=0

The only difference between this run and the failing one is .js on the import line. Express 4.22.3, package.json and the config/app.js file are unchanged.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

The specifier-resolution flags are now silent no-ops#

The third answer on the question suggests nodemon --experimental-modules --es-module-specifier-resolution=node index.js. On Node.js 18.20.8, --experimental-specifier-resolution=node still made the extensionless import load, with a warning:

text
v18.20.8
(node:9) ExperimentalWarning: The Node.js specifier resolution flag is experimental. It could change or be removed at any time.
listening on 3000

The flag was removed in Node.js 19.0.0 (PR #44859, listed in the v19 changelog). What makes it a trap is that current Node does not reject it. In src/node_options.cc on the v24.x branch, --experimental-specifier-resolution, its alias --es-module-specifier-resolution and --experimental-modules are all registered as NoOp options. Running the answer's exact flags on Node.js 24.21.0 starts without complaint and fails with the same ERR_MODULE_NOT_FOUND and Did you mean to import "./config/app.js"?. If a package.json script or a Dockerfile still carries these flags, delete them. They hide nothing and fix nothing.

Keeping extensionless imports with a resolve hook#

If a large codebase cannot be rewritten yet, the Node docs point to module customization hooks as the supported way to change resolution. registerHooks() was added in Node.js 23.5.0 and 22.15.0 and is labelled "Release candidate" in the docs. This hook retries a failed relative import with .js appended:

js
// add-js-extension.js
import { registerHooks } from 'node:module';
 
registerHooks({
  resolve(specifier, context, nextResolve) {
    try {
      return nextResolve(specifier, context);
    } catch (err) {
      const relative = specifier.startsWith('./') || specifier.startsWith('../');
      if (err.code === 'ERR_MODULE_NOT_FOUND' && relative) {
        return nextResolve(`${specifier}.js`, context);
      }
      throw err;
    }
  },
});

Load it before the app with node --import ./add-js-extension.js server.js. On Node.js 24.21.0 the unchanged question code then prints listening on 3000. Use this as a bridge while you migrate, not as the end state. Every tool that reads your imports without this hook (bundlers, test runners, tsc) still sees the extensionless paths.

TS2835: TypeScript reports the same mistake at compile time#

TypeScript projects hit this error at runtime when tsc is set up for a bundler but the output runs on plain Node. With "module": "esnext" and "moduleResolution": "bundler", TypeScript 7.0.2 compiles import app from './config/app'; without an error and copies the line into dist/server.js unchanged. Node then fails:

text
tsc exit=0
import app from './config/app';
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/dist/config/app' imported from /app/dist/server.js

Switch the tsconfig.json to the setting that models Node:

json
{
  "compilerOptions": {
    "module": "nodenext",
    "target": "es2022",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true
  },
  "include": ["src"]
}

Now tsc refuses the import and names the fix:

text
src/server.ts(1,17): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './config/app.js'?

The source file is config/app.ts, but the specifier is ./config/app.js, the name of the file tsc emits. With that one change, tsc exits 0 and node dist/server.js prints loaded config/app. The TypeScript moduleResolution reference describes bundler as the mode for use with bundlers, so keep it for code a bundler processes before it runs. Code that plain Node runs needs nodenext.

Running .ts files directly: the extension is .ts, and no hint#

Node.js 24 runs .ts files itself by stripping types (enabled by default since 23.6.0 and 22.18.0, per the Node.js TypeScript docs). The extension rule still applies. In the words of those docs: "As in JavaScript files, file extensions are mandatory in import statements and import() expressions". node src/server.ts with import app from './config/app' fails with Cannot find module '/app/src/config/app' imported from /app/src/server.ts. In this reproduction no "Did you mean" line was printed, so there is no hint to rely on. The fix is the real file name, import app from './config/app.ts';, after which the same command prints loaded config/app. For tsc to type-check .ts specifiers, the same docs point to the allowImportingTsExtensions option.

The error that follows on a CommonJS config file#

Once the path resolves, a project half-migrated from CommonJS can fail on the next line. If config/app.js still uses require('express') and exports.default = app, "type": "module" makes Node parse it as an ES module. It has no ES exports, and Node 24.21.0 stops at link time:

text
SyntaxError: The requested module './config/app.js' does not provide an export named 'default'

Either convert the file to import and export default, or keep it as CommonJS under a .cjs name with module.exports = app and import ./config/app.cjs, which loads and listens in the reproduction. How Node picks CommonJS or ESM for each file, and why exports or module then become undefined, is covered in Fix: exports is not defined in ES module scope.

Two look-alike messages come from different causes. The message template in Node's lib/internal/errors.js prints "Cannot find package" instead of "Cannot find module" when the failure has no file URL to report. With no node_modules folder, import helmet from 'helmet' gives Cannot find package 'helmet' imported from /app/server.js on Node.js 24.21.0, and no extension will fix that: the package has to be installed. A dependency pruned on a host is one way to get there, as in babel-node not found on a Heroku deploy. If the package is installed but its exports map has no entry for the path you import, the error code is different again: see ERR_PACKAGE_PATH_NOT_EXPORTED: subpath not in exports. The reproduction for this page ran on 11 October 2026 in Docker, on the official node:24-alpine (24.21.0) and node:18 (18.20.8) images, with Express 4.22.3 and TypeScript 7.0.2. When the specifier names a folder instead of a file, Node throws ERR_UNSUPPORTED_DIR_IMPORT rather than ERR_MODULE_NOT_FOUND; see Directory import is not supported resolving ES modules.

Further reading: Import a directory of files in a loop: TS1343, require().

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote