What you'll learn
Quick Answer
Module not found means the resolver looked for a file and did not find one. First check whether the specifier starts with a dot or a slash. If it does, it is a filesystem path and the mistake is in the path, the file extension or the folder depth. If it does not, it is a package name and either the dependency is missing from node_modules or the name is wrong. Case mismatches are the usual reason code works locally but fails in CI.
Read the error before you change anything
Three tools produce three different messages for the same underlying failure, and each one hands you a clue that the others do not. CommonJS in Node prints the require stack, which tells you exactly who asked for the missing module:
Error: Cannot find module './utils/formatFee'
Require stack:
- /app/src/routes/students.js
- /app/src/server.jsThe first entry in that list is the file containing the bad import. That is where you should be looking, not in the file you happened to have open.
ES modules print an absolute path instead, which is even more useful because it shows you precisely where the resolver looked:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'/app/src/routes/helper' imported from /app/src/routes/students.jsBundlers phrase it as a resolution failure and tell you the directory they searched from:
Module not found: Error: Can't resolve 'axios' in '/app/src'Whatever the wording, ask one question first: does the specifier start with ./, ../ or /? If yes, it is a relative or absolute path, the resolver went straight to the filesystem, and no amount of reinstalling packages will help. If no, it is a bare specifier: a package name that Node looks for in node_modules, walking up from the importing file's folder to the filesystem root. Those two failures have completely different fixes, and sorting the error into the right bucket before touching anything saves most of the time people lose here.
The dependency is genuinely missing
Start with the simplest check. npm ls axios tells you whether the package is installed and at which version, and it will say (empty) or report an unmet dependency if it is not there. If it is missing, install it and let npm write it into package.json.
Several situations produce a package that exists on your machine but not where it is needed. Installing globally with npm i -g does not make a package importable from a project; global installs are for command line tools. Putting a runtime dependency in devDependencies works locally and breaks in production, because production installs commonly run npm ci --omit=dev and skip them entirely. That is the classic "works on my machine, crashes on deploy" version of this error.
Switching branches is another trigger. A colleague added a dependency, you pulled the branch, and your node_modules is still the old one. Run npm ci after a branch switch that touches the lockfile; it wipes node_modules and installs exactly what the lockfile specifies, which is both faster and more reliable than npm install for this purpose.
Two more variants deserve names. In TypeScript, Cannot find module 'express' or its corresponding type declarations often means the package is installed but its types are not, so you need @types/express as a dev dependency. And a package can be installed yet still refuse a deep import, giving you Package subpath './lib/internal' is not defined by "exports". That is not a missing file; it is the package deliberately restricting which of its files you may import through its exports map, and the fix is to use a documented entry point.
Relative paths and the base you forgot
Here is the trap that catches almost everyone once. An import path is resolved relative to the file doing the importing. A path passed to fs.readFile is resolved relative to the process working directory. Two identical looking strings, two different bases.
// src/routes/students.js
const fs = require('node:fs');
const db = require('../db'); // src/db.js
const helper = require('./helper'); // src/routes/helper.js
// resolved against wherever you ran `node` from,
// not against src/routes/
const raw = fs.readFileSync('./data/fees.json', 'utf8');That is why a script works when you run it from the project root and fails when you run it from inside src. Build data file paths from __dirname and they stop moving.
Forgetting the leading dot changes the meaning completely. require('utils') looks in node_modules for a package called utils; require('./utils') looks for a sibling file. If the error says a package name you never installed and the name matches one of your own folders, this is why.
Index resolution differs between the two module systems, and this bites people migrating a project. In CommonJS, require('./routes') quietly resolves to ./routes/index.js, and a missing extension is filled in for you. ES modules do neither: import './routes' and import './helper' both fail, and you must write ./routes/index.js and ./helper.js. Bundlers hide this difference by adding extensions themselves, so code that runs perfectly under Vite can fail the moment you execute it with plain Node. In TypeScript with "module": "nodenext", you write the .js extension in your .ts source, because the extension refers to the compiled output.
Case sensitivity: local success, CI failure
Windows with NTFS and macOS with the default APFS setup are case-insensitive but case-preserving. They store Button.jsx exactly as you typed it, and they also open it when you ask for button.jsx. Linux with ext4 does not. Your CI runner, your Docker image and your production server are almost certainly Linux.
// file on disk: src/components/Button.jsx
import Button from './components/button';
// works on Windows and macOS
// Module not found on LinuxThis is a very common reason a build that is green locally turns red in CI with a resolution error, and the reason it feels so unfair is that nothing in your code changed.
Git makes it worse. Because your working tree is case-insensitive, renaming Button.jsx to button.jsx may not register as a change at all, so the repository keeps the old name while your editor shows the new one. Force the rename so it is recorded:
git mv --force Button.jsx button.jsx
git status # confirm the rename is stagedIf Git still refuses, rename to a temporary name, commit, then rename to the final name and commit again.
Prevention is better than detection. Pick one convention for component files and folders and never deviate. Add an import linting rule that checks path casing, or use a webpack plugin that enforces case-sensitive paths in development. The cheapest option of all is to build your project inside a Linux container locally before you push, which turns a failed deploy into a failed local build. Package names on npm are lowercase, so require('Express') fails on Linux too.
Path aliases only work where they are configured
import Button from '@/components/Button' is not a JavaScript feature. Nothing in Node understands the @/ prefix; some tool has to translate it, and every tool in your pipeline needs its own copy of the mapping. That is why aliases work in the dev server, break in Jest, and break again in a production build.
The most misunderstood piece is TypeScript. The paths option in tsconfig.json teaches the type checker where to find types, and nothing more. tsc does not rewrite import specifiers when it emits JavaScript, so your compiled output still contains @/components/Button and Node throws a resolution error at runtime. Type checking passes, the build passes, the app crashes on start. You need a bundler, a post-processing step such as tsc-alias, or a runtime-level mapping.
Node has a built-in mechanism that needs no tooling, using the imports field and specifiers beginning with #:
{
"imports": {
"#lib/*": "./src/lib/*.js"
}
}import { formatFee } from '#lib/format';
// resolves to ./src/lib/format.jsFor everything else, keep the mappings in sync deliberately. Vite reads resolve.alias, Jest reads moduleNameMapper, and each is a separate file you have to remember to update. When an alias resolves in the app but not in tests, that mismatch is the reason.
Two related cases. In a monorepo, an internal package whose main points at dist/index.js is unresolvable until you build it, so a fresh clone fails until the first build runs. And avoid NODE_PATH: it is a legacy escape hatch that makes imports depend on how the process was launched, which is exactly the kind of hidden dependency this whole class of error is made of.
