Node.js module resolution is a surprisingly nuanced process that often trips up developers, leading to "module not found" errors even when the file seems to be right there. At its core, Node.js needs to figure out exactly which file on your filesystem corresponds to a require('module-name') or import 'module-name' statement. This isn’t a simple file lookup; it’s a multi-stage search that involves checking specific locations and applying rules based on the module name.
Let’s see this in action. Imagine a simple project structure:
my-app/
├── index.js
└── lib/
└── utils.js
And lib/utils.js contains:
export function greet(name) {
return `Hello, ${name}!`;
}
Now, in index.js, we want to use greet:
// Using ES Module syntax
import { greet } from './lib/utils.js';
console.log(greet('World'));
// Using CommonJS syntax (if this were a .cjs file or configured as such)
// const { greet } = require('./lib/utils.js');
// console.log(greet('World'));
When Node.js encounters import { greet } from './lib/utils.js'; (or require('./lib/utils.js')), it starts a search.
The Resolution Process
-
Core Modules: First, Node.js checks if
'./lib/utils.js'is a built-in core module (likefs,http,path). It’s not. -
File/Directory Modules: If it’s not a core module, Node.js treats it as a relative or absolute path.
- Exact Match: It first checks if
./lib/utils.jsexactly matches a file on disk. If it exists, Node.js loads it. - Directory with
index: If./lib/utils.jsdoesn’t exist as a file, Node.js checks if it’s a directory. If it is, it looks for anindexfile within that directory, prioritizingindex.js, thenindex.json, thenindex.node. - Package
mainorexports: If./lib/utils.jsis not a file or a directory with anindex, Node.js looks for apackage.jsonfile in that directory. If found, it consults themainfield (for CommonJS) or theexportsfield (for both CommonJS and ES Modules) to determine the entry point.
- Exact Match: It first checks if
-
node_modulesSearch (for non-relative paths): If the module name is not a relative path (i.e., it doesn’t start with./,../, or/), Node.js enters thenode_modulessearch. It looks for the module in:- The
node_modulesdirectory in the current directory. - The
node_modulesdirectory in the parent directory. - This continues up the directory tree until it reaches the root.
- For each
node_modulesdirectory, it looks for a package namedutils(orutils.js,utils.json,utils.node). If it finds a directorynode_modules/utils, it then checksutils/package.jsonformainorexports, or looks forutils/index.js, etc.
- The
Common Pitfalls and How to Fix Them
-
Missing File Extension: When using CommonJS (
require), Node.js will try to append.js,.json, and.nodeif you omit the extension. However, for ES Modules (import), you must include the file extension (e.g.,./lib/utils.js). Omitting it for.jsfiles will cause an error.- Fix: Always include
.js(or.mjs,.cjs) when importing local ES Modules.import { greet } from './lib/utils';will fail;import { greet } from './lib/utils.js';will succeed.
- Fix: Always include
-
package.jsontypeField and File Extensions: Thetypefield inpackage.jsondictates how Node.js interprets.jsfiles.- If
"type": "module",.jsfiles are treated as ES Modules. You’ll need.cjsfor CommonJS files. - If
"type": "commonjs"(or absent),.jsfiles are treated as CommonJS. You’ll need.mjsfor ES Modules. - Fix: Ensure consistency. If your project is primarily ES Modules, set
"type": "module"in yourpackage.jsonand use.jsfor your ESM files. If you need a CommonJS file, name itsomething.cjs.
- If
-
Incorrect
mainorexportsinpackage.json: When requiring/importing a third-party package, Node.js relies on itspackage.json. If themain(for CommonJS) orexports(for ESM/CJS) fields are misconfigured, Node.js won’t find the correct entry point.- Fix: Inspect the
package.jsonof the problematic dependency. For example, if a packagemy-libis installed butrequire('my-lib')fails, checknode_modules/my-lib/package.json. Ifmainis missing or wrong, add it:"main": "index.js". Ifexportsis used, ensure it correctly maps the requested module path to a file:"exports": "./index.js".
- Fix: Inspect the
-
Case Sensitivity: While file systems like Windows are often case-insensitive, Node.js resolution is case-sensitive. If you
require('MyModule')but the file ismyModule.js, it will fail on Linux or macOS.- Fix: Match the case exactly.
require('./lib/Utils.js')will not work if the file isutils.js.
- Fix: Match the case exactly.
-
Relative Path Issues: Developers sometimes forget that relative paths are resolved from the current file’s directory, not the project root.
- Fix: Double-check your relative paths. If
index.jsis in the root andlib/utils.jsis inlib/, thenrequire('./lib/utils.js')(orimport './lib/utils.js') is correct. Ifindex.jswere insrc/andutils.jsinlib/, you’d needrequire('../lib/utils.js').
- Fix: Double-check your relative paths. If
-
NODE_PATHEnvironment Variable: This variable can specify additional directories where Node.js should look for modules. While powerful, it can lead to confusion if not managed carefully.- Fix: Avoid relying on
NODE_PATHfor typical project structures. If you must use it, ensure it’s set correctly and consistently across your development and deployment environments. For local modules, relative paths or package management are preferred.
- Fix: Avoid relying on
The most subtle aspect of Node.js module resolution, especially with ES Modules and the exports field, is how it handles conditional exports and subpath mappings. A package can expose different entry points depending on whether it’s being imported via CommonJS or ES Modules, or even based on specific conditions like require.resolve.conditions. This allows for more sophisticated package design, but it also means that a module that seems to exist might not be directly resolvable if the package.json’s exports field deliberately hides it or maps it to a different internal path.