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

  1. Core Modules: First, Node.js checks if './lib/utils.js' is a built-in core module (like fs, http, path). It’s not.

  2. 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.js exactly matches a file on disk. If it exists, Node.js loads it.
    • Directory with index: If ./lib/utils.js doesn’t exist as a file, Node.js checks if it’s a directory. If it is, it looks for an index file within that directory, prioritizing index.js, then index.json, then index.node.
    • Package main or exports: If ./lib/utils.js is not a file or a directory with an index, Node.js looks for a package.json file in that directory. If found, it consults the main field (for CommonJS) or the exports field (for both CommonJS and ES Modules) to determine the entry point.
  3. node_modules Search (for non-relative paths): If the module name is not a relative path (i.e., it doesn’t start with ./, ../, or /), Node.js enters the node_modules search. It looks for the module in:

    • The node_modules directory in the current directory.
    • The node_modules directory in the parent directory.
    • This continues up the directory tree until it reaches the root.
    • For each node_modules directory, it looks for a package named utils (or utils.js, utils.json, utils.node). If it finds a directory node_modules/utils, it then checks utils/package.json for main or exports, or looks for utils/index.js, etc.

Common Pitfalls and How to Fix Them

  • Missing File Extension: When using CommonJS (require), Node.js will try to append .js, .json, and .node if you omit the extension. However, for ES Modules (import), you must include the file extension (e.g., ./lib/utils.js). Omitting it for .js files 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.
  • package.json type Field and File Extensions: The type field in package.json dictates how Node.js interprets .js files.

    • If "type": "module", .js files are treated as ES Modules. You’ll need .cjs for CommonJS files.
    • If "type": "commonjs" (or absent), .js files are treated as CommonJS. You’ll need .mjs for ES Modules.
    • Fix: Ensure consistency. If your project is primarily ES Modules, set "type": "module" in your package.json and use .js for your ESM files. If you need a CommonJS file, name it something.cjs.
  • Incorrect main or exports in package.json: When requiring/importing a third-party package, Node.js relies on its package.json. If the main (for CommonJS) or exports (for ESM/CJS) fields are misconfigured, Node.js won’t find the correct entry point.

    • Fix: Inspect the package.json of the problematic dependency. For example, if a package my-lib is installed but require('my-lib') fails, check node_modules/my-lib/package.json. If main is missing or wrong, add it: "main": "index.js". If exports is used, ensure it correctly maps the requested module path to a file: "exports": "./index.js".
  • Case Sensitivity: While file systems like Windows are often case-insensitive, Node.js resolution is case-sensitive. If you require('MyModule') but the file is myModule.js, it will fail on Linux or macOS.

    • Fix: Match the case exactly. require('./lib/Utils.js') will not work if the file is utils.js.
  • 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.js is in the root and lib/utils.js is in lib/, then require('./lib/utils.js') (or import './lib/utils.js') is correct. If index.js were in src/ and utils.js in lib/, you’d need require('../lib/utils.js').
  • NODE_PATH Environment 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_PATH for 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.

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.

Want structured learning?

Take the full Nodejs course →