Cannot find module: 6 Reasons Node.js Can't Find Your Module (and How to Fix Each)
Symptom: two error forms, one root cause
Error: Cannot find module 'express'
Require stack:
- /app/server.js
Or the ESM form:
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'lodash' imported from /app/index.js
Both mean: Node can't locate the module file you asked it to load. Either the file doesn't exist, the path is wrong, or it's not installed.
Six causes, ranked by frequency
1. Not installed (most common for newcomers)
npm install express
Quick check: ls node_modules/express — does the directory exist?
2. Missing ./ prefix on relative paths
require('utils'); // ❌ Node looks in node_modules for a package named "utils"
require('./utils'); // ✅ relative paths must start with ./ or ../
The rule: any path not starting with ./, ../, or / goes to node_modules.
3. Corrupted node_modules (interrupted install, disk full)
rm -rf node_modules package-lock.json
npm install
Delete-and-reinstall fixes most weirdness — node_modules is ephemeral by design, reinstalling is lossless.
4. Case mismatch (works on macOS, breaks on Linux)
macOS filesystems are case-insensitive by default, so require('./Utils') finds utils.js. Linux (including Docker containers and CI servers) is case-sensitive — works locally, breaks in production is the classic symptom.
# Check actual casing
ls -la src/utils/ | grep -i util
5. ESM/CommonJS mismatch
After adding "type": "module" to package.json, all .js files are parsed as ESM:
| Syntax | CommonJS (.js) | ESM ("type":"module") |
|---|---|---|
require('./x') | ✅ | ❌ ERR_MODULE_NOT_FOUND |
import x from './x.js' | ❌ | ✅ (extension required) |
Two key ESM differences: import paths must include the file extension (./utils.js, not ./utils), and bare specifiers without extensions fail.
6. Monorepo / workspace path issues
In a monorepo referencing sibling packages, verify that package.json's workspaces includes the package, and that the import name matches the package's name field exactly.
Why deleting node_modules fixes 80% of cases
node_modules integrity depends on npm install running to completion. A network dropout, Ctrl+C, or disk-full during install can leave a half-installed state — the directory exists but the package's internal package.json (describing its own dependencies) is missing. Reinstalling rebuilds the entire tree, which is faster than surgical repair.
Prevention
- Use
npm ciin CI instead ofnpm install—ciinstalls strictly from the lock file and errors on mismatch instead of silently resolving - Run a type check before adding
skipLibCheck— rules out third-party type errors as a confounder - Use workspace protocol in monorepos (
"deps": {"@repo/utils": "workspace:*"}) instead of relative paths
The three invisible causes beyond the checklist
(1) Case-sensitivity drift: code developed on macOS (case-insensitive) referencing ./utils/Logger when the file is logger.js — works locally, dies on the Linux CI/container. The one-command audit: run the project in a case-sensitive disk image or use tsconfig's forceConsistentCasingInFileNames before the CI finds it for you. (2) Exotic characters in the path: spaces and non-ASCII in the project directory break some bundlers' module resolution on Windows — the error appears as module-not-found with no hint the path is the culprit. (3) The module resolves but throws during evaluation — require() reports it as "Cannot find module" in some stacks when the real error is inside the module file (a syntax error, a failed import of its own). Reading the stack's innermost frame, not the first line, separates these instantly.
ESM vs CJS: when the same fix book says different things
require() resolves .js with implicit extensions and directory index.js; ES imports want explicit-enough specifiers and type: module or .mjs. A CJS fix (adding to package.json dependencies) is useless if the file is ESM and the package exports map does not expose the subpath you import — the exports map is the gatekeeper modern packages use, and importing pkg/lib/internal that is not in the map fails as not-found even though the file sits in node_modules. The diagnostic split: run node -e "console.log(require.resolve('pkg'))" (CJS) versus node --input-type=module -e "import('pkg').then(console.log)" — the one that works tells you which resolution world you are in.
Postmortem template for the recurring case
When the same module error returns monthly, the fix was symptom-level. The durable template: reproduce with a two-line test file (imports the module, prints ok) — if the test file works but the app does not, the problem is configuration (paths, tsconfig baseUrl, bundler alias), not the module. Pin that test into CI; the next drift fails a five-second check instead of a debugging afternoon.
TypeScript paths vs runtime resolution
tsconfig.json paths / baseUrl only teach the TypeScript compiler (and sometimes the bundler) where to look. Node itself does not read those aliases at runtime unless you add a loader (tsx, ts-node, module-alias) or compile them away with a bundler. The classic failure: import '@/lib/x' type-checks in the IDE, then node dist/server.js throws ERR_MODULE_NOT_FOUND because the emitted JS still contains @/. Fix by either rewriting aliases at build time or switching runtime imports to relative / package exports. When debugging, open the compiled file — if the alias is still there, the problem is the build pipeline, not npm.
Conditional exports and dual packages
Modern packages ship an exports map with import / require / default conditions. Importing a subpath that is not listed fails as “not found” even when the file exists under node_modules. Dual CJS/ESM packages can also expose different entry files per condition; requiring the ESM-only entry from CJS (or the reverse) surfaces as module-not-found or ERR_REQUIRE_ESM. Check node -p "require('pkg/package.json').exports" (or open the file) before filing a bug against the package.
The checklist
ls node_modules/<package>— does the directory exist?- Check path starts with
./(relative import) rm -rf node_modules && npm install— reinstall- Case sensitivity check (works locally → breaks on deploy = likely this)
"type": "module"check — does require vs import match the project type?- Compiled output: are
pathsaliases rewritten, and is the subpath allowed byexports?
Triage notes
The six causes are ordered by likelihood, but the third — delete node_modules and reinstall — gets its own explanation of why it fixes 80% of cases: a half-installed state left by an interrupted install is cheaper to rebuild from scratch than to repair precisely, and since node_modules is a disposable artifact, reinstalling loses nothing. The case-sensitivity entry is flagged as the classic "works locally, breaks on deploy" because macOS filesystems are case-insensitive by default while Linux, containers, and CI are not. The three hidden causes beyond the checklist share one piece of advice: read the bottom frame of the stack, not the first line of the message — some toolchains report a module's own syntax error as Cannot find module.
Provided by ToolVault. Related: Linux Cheatsheet, npm mirror guide, ERESOLVE conflicts, Node 17+ OpenSSL error. See the homepage for more developer tools.
Related Tools
Related Articles
Linux Crontab in Practice: Commands, Logs, and Debugging
Crontab beyond syntax: three must-know commands, /etc/cron.d vs user crontabs, five-step debugging, PATH/timezone/% escaping, MAILTO, and what to do when you need second-level scheduling.
docker run to docker-compose: The Complete Migration Guide (with Flag Map)
Migrate docker run commands to docker-compose.yml: five advantages of compose over raw run, a flag-by-flag mapping table (ports, volumes, env, restart, networks), the classic pitfalls (bare -e passthrough, anonymous volumes, --rm semantics), and an online converter that runs locally in your browser.
Permission denied (publickey): 6 Reasons Git Push Fails Over SSH (and the Fix for Each)
git clone or push rejected with Permission denied (publickey) fatal: Could not read from remote repository? Covers missing keygen, key not loaded in the agent, public key not added to GitHub/GitLab, multi-account key routing with ~/.ssh/config, deploy key limits, and wrong remote URLs — with ssh -v diagnostics.