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.js

This 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.