How Node Resolves a Specifier

A specifier is the string in require('...') or import '...'. Node sorts it into one of four kinds and resolves it to an absolute file URL before any code is read. Builtin specifiers (node:fs) never touch disk; the bare form fs can be shadowed by a node_modules/fs, the prefixed form cannot. Relative (./routes/users.js) and absolute (file:///srv/app/main.js) specifiers are joined against the importing file's URL. Bare specifiers (express, @shop/api/config) go through the node_modules search.

Resolving one specifier, from string to loaded module
Resolving one specifier, from string to loaded module

The node_modules walk is the part that surprises people. Starting from the directory of the importing file, Node appends node_modules and tries the package there, then repeats one directory up, to the filesystem root. There is no global path, and NODE_PATH is ignored for ES modules entirely.

Inspecting the resolver from inside a moduleJavaScript
import { createRequire } from 'node:module';
import { relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const here = import.meta.dirname;
console.log(relative(here, fileURLToPath(import.meta.resolve('geo'))));
console.log(require.resolve.paths('node:fs'));
console.log(require.resolve.paths('geo').map((p) => relative(here, p)).slice(0, 3));
Output
node_modules\geo\index.js
null
[ 'node_modules', '..\\node_modules', '..\\..\\node_modules' ]

require.resolve.paths() returns null for builtins because they are not searched for, and the array it returns for a bare name is exactly the walk in the diagram. import.meta.resolve() is the ESM counterpart; it returns a URL string synchronously and is a release candidate as of Node 26.

Two rules catch newcomers. import requires the file extension for relative and absolute specifiers: ./routes/users does not resolve, and neither does a bare directory -- write ./routes/index.js. CommonJS still guesses, trying .js, .json, .node and then index.*, but the documentation marks that directory-as-module behavior legacy. And resolution runs on resolved symlinks, so an npm 2,036 linked package resolves inside its real location and picks up that location's package scope.