How Node.js Resolves Extensions and Indexes in ESM
When transitioning from CommonJS to ECMAScript Modules (ESM) in Node.js, developers often encounter module resolution errors due to differences in how files are located. This article explains how Node.js handles file extensions and directory index files under the ESM specification, highlighting the strict requirements for explicit file paths and the reasoning behind these changes.
Strict Specifier Resolution in ESM
In CommonJS, the module resolution algorithm is highly permissive. If
you write const helper = require('./helper'), Node.js
automatically searches for ./helper.js,
./helper.json, or ./helper.node.
In ESM, Node.js adopts the browser-compatible ES Module resolution algorithm. By default, Node.js does not perform automatic extension lookup. You must provide the exact, fully specified file URL, including the file extension.
// This will throw an ERR_MODULE_NOT_FOUND error in ESM:
import { helper } from './helper';
// This is the correct way:
import { helper } from './helper.js';This strictness applies to relative specifiers (starting with
./ or ../) and absolute specifiers (starting
with /).
Directory Index Resolution
Under CommonJS, pointing a require statement to a folder, such as
require('./utils'), instructs Node.js to look for an
index.js file inside that folder.
In ESM, automatic directory index resolution is disabled. If you want to import from an index file within a folder, you must explicitly specify the path to the index file including its extension.
// This will throw an error in ESM:
import { format } from './utils';
// This is the correct way:
import { format } from './utils/index.js';Why Did Node.js Change This Behavior?
The primary reason for this strictness is browser compatibility and performance. In a web browser environment, resolving missing extensions or index files requires making multiple sequential HTTP requests to guess the correct file path. This introduces significant latency. By forcing explicit file extensions, Node.js ensures that ESM code is highly compatible with web browsers and standard-compliant.
Exceptions and Workarounds
While explicit extensions are the standard, Node.js offers a few ways to bypass these rules under specific circumstances:
1. Bare Specifiers (Node Modules)
Bare specifiers (e.g., import express from 'express') do
not require extensions. Node.js resolves these by looking at the
node_modules directory and reading the package’s
package.json to find the entry point specified in the
exports or main fields.
2. Using Package Exports
You can configure custom, extension-free import paths for your own
project using the imports field in your
package.json. This allows you to define aliases that map to
specific files behind the scenes.
3. The Experimental Resolution Flag
If you are migrating a large codebase and cannot immediately update
all import statements, you can run Node.js with the
--experimental-specifier-resolution=node flag:
node --experimental-specifier-resolution=node server.jsThis flag forces Node.js to use the CommonJS-style resolution algorithm for ESM, automatically resolving file extensions and index files. However, this is discouraged for production environments as it is experimental and deviates from the ESM standard.