Module Resolution
When you write import 'express' or require('express'), Node follows a deterministic resolution algorithm to locate the actual file on disk. Understanding this algorithm helps you debug missing-module errors, understand why subpath imports work, and reason about how ESM and CJS packages interoperate.
Bare specifiers
Section titled “Bare specifiers”A bare specifier is a module name that does not start with ./, ../, or / — for example 'express' or 'lodash/fp'. Node resolves bare specifiers by walking up the directory tree from the importing file, checking each node_modules/ folder along the way until it finds a match or reaches the filesystem root.
# Given a file at /project/src/app.js importing 'express',# Node checks these locations in order:/project/src/node_modules/express/project/node_modules/express ← found here/node_modules/expressThis traversal is what allows nested packages to carry their own dependency versions without conflicting with siblings.
The resolution walk, visually
Section titled “The resolution walk, visually”flowchart TD
A["Specifier string"] --> B{"Starts with './' , '../' , or '/' ?"}
B -->|"yes (relative/absolute)"| C{"File extension?"}
C -->|".mjs"| D["Always ESM"]
C -->|".cjs"| E["Always CJS"]
C -->|".js or none"| F["Check nearest package.json 'type'"]
B -->|"no (bare specifier)"| G["Walk up node_modules/ from the importing file"]
G --> H{"Package found with 'exports' field?"}
H -->|"yes"| I["Match subpath against exports map, first matching condition wins"]
H -->|"no"| J["Fall back to 'main', then index.js"]
I --> K["Resolved file"]
J --> K
F --> K
D --> K
E --> K File extensions
Section titled “File extensions”Node resolves specifiers to concrete files using these rules:
.js— treated as CJS by default, or ESM when the nearestpackage.jsonhas"type": "module"..mjs— always ESM, regardless ofpackage.json..cjs— always CJS, regardless ofpackage.json.
ESM enforces explicit extensions in specifiers. Unlike CJS, you cannot omit the extension and rely on Node to guess:
// @skip-verify multi-file: illustrative// CJS — extension optional (Node tries .js, .json, index.js …)const utils = require('./utils');
// ESM — extension requiredimport utils from './utils.js';ESM vs CJS interop
Section titled “ESM vs CJS interop”The two module systems can coexist, but with constraints:
// @skip-verify multi-file: illustrative// ── CJS file (utils.cjs) ─────────────────────────────────────// CJS can require other CJS modules normally.const fs = require('fs');module.exports = { readConfig: () => fs.readFileSync('.env', 'utf8') };
// Since Node 22.12 (and 20.19 LTS), CJS CAN require() an ESM module:// const esm = require('./modern.mjs'); // ✅ (throws ERR_REQUIRE_ASYNC_MODULE only if it uses top-level await)
// ── ESM file (app.mjs) ──────────────────────────────────────// ESM CAN import a CJS module; it receives module.exports as the default.import cjsUtils from './utils.cjs'; // ✅ default = module.exports object
// ESM can also import another ESM module normally.import { helper } from './helpers.mjs'; // ✅Historically CJS could not require() an ESM module — it threw ERR_REQUIRE_ESM — because ESM loading is static and asynchronous while CJS loading is synchronous. Since Node 22.12 (and 20.19 LTS), require() of an ESM module works by default, as long as that module has no top-level await. A module that uses top-level await still throws ERR_REQUIRE_ASYNC_MODULE, because a synchronous require() call cannot wait for an async evaluation.
The practical consequence for library authors: you no longer need to ship both CJS and ESM builds. Publishing ESM only works on every supported Node line (22, 24, 26), because CJS consumers can require() it. The dual export map in the next section still helps older tooling, but it is no longer mandatory.
The exports map
Section titled “The exports map”The "exports" field in package.json is the authoritative way for a package to declare its public API surface. Any path not listed in "exports" is private — consumers cannot import it directly.
{ "name": "my-lib", "version": "1.0.0", "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" }, "./utils": { "import": "./dist/utils.mjs", "require": "./dist/utils.cjs" } }}With this map:
import 'my-lib'resolves todist/index.mjs.require('my-lib')resolves todist/index.cjs.import 'my-lib/utils'resolves todist/utils.mjs.import 'my-lib/internal/secret'throws —"./internal/secret"is not exported.
Exports condition order is significant
Section titled “Exports condition order is significant”Inside a conditions object, Node checks keys in the order they are written and uses the first one it recognises — it does not pick the “most specific” or “best” match. If two conditions could both apply, whichever is listed first wins, so a careless reordering silently changes what a package resolves to. When a "types" condition is present (for TypeScript consumers), it must be listed first in every conditions object — Node’s own resolver ignores it, but listing it after "import"/"require" risks a resolver that does not special-case "types" matching the wrong file first.
# scratch package: exports = { ".": { "import": "./esm.mjs", "default": "./cjs.cjs" } }$ node -e "import('order-demo').then(m => console.log(m.via))"import-wins
# Reorder to { "default": "./cjs.cjs", "import": "./esm.mjs" } — same import() call:$ node -e "import('order-demo').then(m => console.log(m.via))"undefined # "default" matched first, so import() got the CJS build insteadThe imports map and self-reference
Section titled “The imports map and self-reference”"exports" controls what outside consumers can reach; "imports" is the equivalent map for a package’s own internal aliases, always spelled with a leading # so they can never collide with a bare specifier from node_modules. It is a clean replacement for fragile relative paths like ../../../shared/utils.js scattered across a codebase.
A package can also import itself by its own name. This works only because "exports" is present — self-reference is not available on a package with no "exports" field — and it means library code can import { helper } from 'my-lib' internally, exercising the exact same public entry point its consumers use, instead of reaching into internal relative paths.
Output (verified on Node 24)
HELLO DANA!
HELLO SAM! / HELLO SAM!#utils inside index.js never touches node_modules lookup at all — it resolves straight through the package’s own "imports" map. The self-reference import (from 'demo-imports') goes through the exact same "exports" map an external consumer would use, which is why greet and selfGreet produce identical output: they are, literally, the same exported function.
Dual-package hazard
Section titled “Dual-package hazard”An "exports" map that points "require" and "import" at two separate files is a common shape for shipping both CJS and ESM builds. The hazard: if those two files each define their own copy of a class (or any other stateful singleton) instead of sharing one source of truth, consumers who reach the package through both conditions end up with two unrelated implementations that only look identical.
Output (verified on Node 24)
cjsInstance instanceof CjsMoney: true
cjsInstance instanceof esmMod.Money: false
esmInstance instanceof CjsMoney: false
esmInstance instanceof esmMod.Money: truerequire() always resolves through the "require" condition and import() always resolves through the "import" condition, regardless of which module system the caller itself uses — that part is not the hazard. The hazard is that index.cjs and index.mjs each declare their own Money class. One part of an application using require('demo-dual') and another part using import('demo-dual') end up holding instances that fail every instanceof check against each other, despite both claiming to be “the same package’s Money”. This is exactly the failure mode the TypeScript course’s build and emit lesson covers from the compiler side — emitting genuinely separate CJS and ESM outputs from one source file, rather than one build both conditions point at, is what avoids it.
NODE_PATH — and why you should not reach for it
Section titled “NODE_PATH — and why you should not reach for it”NODE_PATH is an environment variable that adds extra directories to Node’s module search path, letting a bare specifier resolve outside the normal node_modules walk. It still works today, but it predates the resolution algorithm this page describes and creates problems the exports/imports maps were designed to avoid:
- It is not part of the resolved file’s identity. The exact same codebase resolves differently depending on an environment variable set outside the project —
git cloneandnpm installare no longer sufficient to reproduce how imports resolve. - It silently shadows real dependency resolution. A module found via
NODE_PATHis not recorded anywhere inpackage.jsonor the lockfile, so nothing documents that the dependency exists. - It does not compose with
"exports". Conditional exports, subpath restrictions, and self-reference all assume the standardnode_moduleswalk;NODE_PATHbypasses that walk entirely.
The portable replacement for “I want a shortcut path to some internal module” is the "imports" map covered above — it is checked into package.json, versioned with the code, and works identically for every developer and every CI run without any environment setup.
Running inline ESM with --input-type
Section titled “Running inline ESM with --input-type”You can pipe ESM source directly to Node without a file on disk:
echo "import os from 'node:os'; console.log(os.platform());" | node --input-type=moduleThis is useful for quick experiments or CI one-liners that need ESM semantics without touching the project’s package.json.
Importing a built-in module
Section titled “Importing a built-in module”Needs the Node.js runtime — open in StackBlitz to run.
Mental Model
Section titled “Mental Model”Resolution is a lookup algorithm applied fresh at every specifier, not a cache of “where things are.” Node never asks “where did I last find express” — for a bare specifier it walks up from the current importing file’s own location through every node_modules on the way to the root, and for a package with "exports", it walks a conditions object top-to-bottom taking the first match. Two files ten directories apart can resolve the same bare specifier to two entirely different installed versions, and that is not a bug — it is the mechanism that lets nested dependencies avoid version conflicts.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Define "exports" with strict subpaths | Locks down the public API; refactor internals freely without breaking consumers | Anything not listed becomes instantly unreachable — a real breaking change for anyone relying on an undocumented internal path |
Ship both "require" and "import" conditions from separate files | Supports CJS and ESM consumers from one package | Dual-package hazard: any shared state or class defined separately in each file diverges across the two module graphs |
Use "imports" / #alias for internal paths | Versioned, refactor-safe, no relative-path spaghetti | One more map to keep in sync as files move |
Reach for NODE_PATH | Zero config changes to package.json | Non-reproducible across machines/CI; invisible to anyone reading the dependency tree |
Common Misconceptions
Section titled “Common Misconceptions”- “Node picks the ‘best’ or ‘most specific’ matching condition in an exports map.” It does not rank conditions at all — it takes the first key it recognises, in the order the object is written. A more “correct-looking” condition listed second never gets a chance.
- “Requiring and importing the same package always gives you the same object.” Only if the
"exports"map points both conditions at the same file (or the same file re-exported). Point them at two different files and you get two independent module instances — the dual-package hazard. - “
NODE_PATHis just an older, equivalent way to add search paths.” It bypasses the entire"exports"/"imports"resolution model and is invisible to anything that readspackage.jsonor the lockfile — it is not a drop-in equivalent, it is an escape hatch with real reproducibility costs.
Reading AI-generated code
Section titled “Reading AI-generated code”Generated code that ships a package with both a CJS and an ESM build frequently reuses the same class or module-level state pattern in each build without noticing that they are, resolution-wise, two entirely separate files. The bug only appears once a consumer’s dependency graph loads the package through both conditions at once — invisible in a single-entry-point test.
🐛 Spot the bug
{"name": "state-store","version": "1.0.0","exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs", "types": "./dist/index.d.ts", "default": "./dist/index.cjs" }}}Show the answer
"types" must be first in the conditions object, not third. Node’s own resolver ignores "types" (it only matters to TypeScript tooling), but a resolver that does not special-case "types" — including some editor integrations — will match whichever condition comes first, here "import", and silently serve the wrong declaration file or none at all. "default" is correctly last (it is meant as the catch-all), but the fix for "types" is to move it to the very first key: { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs", "default": "./dist/index.cjs" }.