The CommonJS loader (require) prints this when it cannot resolve a name (Node v22.23.2; paths shortened, internal frames trimmed):
node:internal/modules/cjs/loader:1433
throw err;
^
Error: Cannot find module 'express'
Require stack:
- /home/dev/shop-api/src/server.js
at Function._resolveFilename (node:internal/modules/cjs/loader:1430:15)
...
code: 'MODULE_NOT_FOUND',
requireStack: [ '/home/dev/shop-api/src/server.js' ]
}
Node.js v22.23.2The ES module loader (import) reports the same problem with a different code and wording:
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'express' imported from /home/dev/shop-api/src/server.jsError [ERR_MODULE_NOT_FOUND]: Cannot find module '/home/dev/shop-api/src/utils/format' imported from /home/dev/shop-api/src/server.js
Did you mean to import "./utils/format.js"?All three mean the same thing: Node followed its resolution rules and no file existed at the end. The fix depends on whether the missing thing is a package, a relative path or your entry file.
Quick fix checklist
- Package name in the error? Run
npm ls <name>; if it is missing,npm install <name>and check it landed independencies. - Relative path? Compare it letter by letter with the real file name, including upper and lower case.
- Using
import? Add the file extension (./db.js) and never import a bare directory. - Error mentions your entry file (
Cannot find module '/.../app.js'with an empty require stack)? You rannodefrom the wrong directory or misspelled the script path. - Error points inside
node_modules/<pkg>? The package’smainorexportspoints at a file that was never built or published. - Works locally, fails in CI or Docker? Suspect case sensitivity, a missing build step, or a monorepo dependency that only worked because of hoisting.
Before you start
Know which module system your file uses. A .mjs file, or a .js file under a package.json with "type": "module", is ESM; .cjs files, and .js files without that field, are CommonJS. The rules differ, and the error code tells you which loader failed: MODULE_NOT_FOUND comes from require, ERR_MODULE_NOT_FOUND from import. Have a terminal open in the project root and Node 22 or 24 with npm 10 or later.
Why it happens
Node classifies every specifier before searching:
- Relative or absolute (
./db,../lib/x.js,/srv/app/x.js): resolved against the directory of the importing file, not your shell’s working directory. - Bare (
express,@acme/shared,lodash/fp): Node looks innode_modulesnext to the importing file, then in the parent directory’snode_modules, and so on up to the filesystem root. You can print the search list withnode -p "require.resolve.paths('express')". - Built-in (
node:fs): never searched on disk.
Once a package folder is found, Node reads its package.json. If the package has an exports field, only the paths listed there are reachable, and anything else fails with ERR_PACKAGE_PATH_NOT_EXPORTED (a sibling error worth recognising). Without exports, require uses main, falling back to index.js. When main points at a file that does not exist, CommonJS says so explicitly: Please verify that the package.json has a valid "main" entry.
The two loaders then diverge. require('./db') tries ./db, ./db.js, ./db.json, ./db.node and ./db/index.js. ESM does none of that: the specifier is a URL, and import './db' asks for a file literally named db. Importing a folder fails with ERR_UNSUPPORTED_DIR_IMPORT. Node’s “Did you mean” hint covers the common case, but only when a matching file exists.
Finally, the file system matters. macOS (APFS by default) and Windows treat Format.js and format.js as the same file; most Linux file systems do not. A require('./utils/Format') that runs fine on a Mac fails in a Linux container or CI runner, which is why this error so often appears “only in production”.
Step-by-step walkthrough
Step 1: Read the specifier and the importer
Two parts of the message carry all the information: the quoted specifier, and the file that asked for it (Require stack, first entry, or imported from). Write both down. A missing 'express' imported from src/server.js is an install problem; a missing '/home/dev/shop-api/app.js' with requireStack: [] is a launch problem, because Node is complaining about the file you passed on the command line.
Step 2: For packages, prove whether it is installed and reachable
npm ls express
node -p "require.resolve('express')"
node --input-type=module -e "console.log(import.meta.resolve('express'))"npm ls prints (empty) when the package is not in the tree. If it is listed but resolution still fails, the package is installed somewhere the importing file cannot see, for example in a sibling folder, in a global prefix, or only in a different workspace. Globally installed packages are not on the require search path; npm install -g is for command-line tools, not libraries.
If the package exists but the error points inside it, open node_modules/<pkg>/package.json and check main and exports against the files that are actually there. A package that publishes TypeScript sources without its dist/ folder, or your own workspace package whose build has not run yet, fails here.
Step 3: For paths, check the directory, the case and the extension
ls src/utils
git ls-files src/utilsgit ls-files shows the case Git stored, which is what CI and Docker will check out. If it says src/utils/format.js and your code says ./utils/Format, rename the import. When you rename only the case of a file on macOS or Windows, use git mv Format.js format.js; a plain rename may not register as a change on a case-insensitive file system.
In ESM, add the extension:
// fails: ERR_MODULE_NOT_FOUND
import { formatPrice } from './utils/format';
// works
import { formatPrice } from './utils/format.js';Step 4: Check the working directory for entry files
node src/server.js resolves src/server.js against the shell’s current directory. Run it from / or from a subfolder and you get Cannot find module '/src/server.js'. This shows up in cron jobs, systemd units and Docker CMDs that do not set a working directory. Use WORKDIR in Docker, WorkingDirectory= in systemd, or an absolute path. Inside your code, build file paths from import.meta.dirname (ESM, Node 20.11+) or __dirname (CommonJS) rather than relying on process.cwd().
Step 5: Look for hoisting in monorepos
npm workspaces link every workspace package into the root node_modules and hoist shared dependencies there. A package that uses @acme/shared without declaring it still works, because resolution walks up to the root. Then a Docker build that copies only packages/api, or a switch to pnpm’s isolated layout, removes that accident and the import fails. The fix is to declare every dependency in the package that uses it:
npm install @acme/shared --workspace @acme/apiWorked scenario
A TypeScript API builds with tsc and runs node dist/server.js. The package has "type": "module". Locally, developers use tsx, which resolves extensionless imports, so everything works. In CI the compiled output crashes:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/dist/db' imported from /app/dist/server.js
Did you mean to import "./db.js"?The source was:
import { pool } from './db';and tsconfig.json used "moduleResolution": "bundler", which tells TypeScript that a bundler will resolve extensionless paths later. Nothing did: tsc emitted ./db unchanged and Node’s ESM loader refused to guess.
The fix makes TypeScript check imports the way Node runs them:
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist"
}
}import { pool } from './db.js';Writing .js in a .ts file looks odd, but it names the file that will exist at runtime. With NodeNext, tsc reports a missing extension at compile time instead of letting it reach production.
Common mistake
The reflex fix is rm -rf node_modules package-lock.json && npm install. It helps when an install was interrupted, but it changes nothing for a wrong path, a case mismatch, a missing extension or a missing build step, and deleting the lockfile silently upgrades every dependency within its semver range. Delete node_modules if you must, keep the lockfile, and use npm ci to reinstall exactly what it records.
Other tempting fixes that hide the real problem:
- Installing the package globally.
requiredoes not search the global prefix, so this usually does not even work, and when it seems to (throughNODE_PATH) the project depends on machine state no one else has. - Setting
NODE_PATH. It only affectsrequire, is ignored by the ESM loader, and makes imports resolve differently on each machine. - Adding the missing package to the root of a monorepo. It hides the undeclared dependency instead of declaring it in the workspace that uses it.
Verify the behavior
Resolution can be tested without running the app:
node -p "require.resolve('express')"
node --input-type=module -e "console.log(import.meta.resolve('./src/utils/format.js'))"Each should print a real path or file:// URL. Then reproduce the environment that failed: run npm ci && npm run build && node dist/server.js from a clean checkout, ideally on Linux (a CI job or a node:22 container) so case mismatches surface before deploy. For monorepos, install and run the single workspace in isolation; if it starts, its dependency list is complete.
Interview exercise
“An import works on every developer laptop but fails in the Linux Docker image with Cannot find module './Models/User'. Explain why and how you would stop it from happening again.”
Answer and reasoning
Developer laptops are typically macOS or Windows with case-insensitive file systems, so ./Models/User finds models/user.js. The Linux image uses a case-sensitive file system, so the lookup fails. Node is behaving consistently; the file system answer differs. I would confirm with git ls-files to see the stored names, fix the import (or rename with git mv so Git records the case change), and add guards: TypeScript’s forceConsistentCasingInFileNames (on by default in recent versions), the import/no-unresolved lint rule with case-sensitive checking, and CI that builds and starts the app on Linux. The broader point is that tests on a developer’s file system do not prove behaviour on production’s file system, so resolution needs to be exercised in the target environment.