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.js

The 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.js

Bundlers 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 Linux

This 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 staged

If 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.js

For 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.

Frequently Asked Questions

Should I delete node_modules whenever I see this error? Only after you have ruled out a path mistake. If the specifier starts with a dot, node_modules is irrelevant and deleting it wastes several minutes. If it is a package name, prefer npm ci over deleting by hand, because it removes node_modules and installs exactly what the lockfile describes, which also rules out a half-updated tree from an interrupted install.
Why does my import work in development but fail in the production build? Three common reasons. The dependency sits in devDependencies and production installs skip it. The path has a case mismatch that only a Linux filesystem notices. Or the alias is configured in the dev server but not in the production build config. Reproducing the production install locally with npm ci --omit=dev, ideally inside a Linux container, exposes all three.
What is the difference between Cannot find module and Can't resolve? They describe the same failure from different layers. Cannot find module comes from Node's own runtime resolver as your code executes. Can't resolve comes from a bundler such as webpack or Vite while it is building the dependency graph, before anything runs. The bundler message includes the directory it searched from, which usually makes it easier to debug.
Do I need file extensions in my imports or not? In CommonJS you do not, because Node will try .js, .json and .node and will look for index files in directories. In ES modules you do, for every relative import. Bare package specifiers never need one. Bundlers add extensions for you regardless, which is precisely why bundled code often fails when run directly with Node.
How do I stop case-sensitivity bugs from reaching CI? Standardise on one naming convention and enforce it with a lint rule that validates import path casing. Beyond that, run at least one build on Linux before merging, either in CI on every pull request or locally in a container. Also make sure renames that only change case are committed with git mv --force, otherwise the repository keeps the old spelling.