Node.js Error

ERR_UNSUPPORTED_DIR_IMPORT

Complete reference for Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import '...' is not supported resolving ES modules — every cause, every fix, and why the --experimental-specifier-resolution=node advice you found elsewhere no longer works.

Quick Answer: Node.js ESM does not auto-resolve directory imports to index.js. The fastest fix is to add the explicit file extension: change import './utils' to import './utils/index.js'. If you are on Node 19+, ignore any advice to use --experimental-specifier-resolution=node — that flag was removed.

What is ERR_UNSUPPORTED_DIR_IMPORT?

ERR_UNSUPPORTED_DIR_IMPORT is thrown by the Node.js ES module loader when an import statement targets a directory path rather than a specific file. CommonJS's require() silently resolved require('./utils') to ./utils/index.js (or the file named by the directory's package.json main field). The ES module specification explicitly forbids this automatic resolution to maintain parity with how browsers load modules — a browser cannot guess a default file for a directory URL.

The error was introduced alongside Node.js's ESM support and remains in every current Node.js version. It is one of the most common errors when migrating a CommonJS project to ESM.

Full Error Example

Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import
'/home/user/project/src/utils' is not supported
resolving ES modules imported from /home/user/project/src/app.js
Did you mean to import ./utils/index.js?
    at new NodeError (node:internal/errors:405:5)
    at finalizeResolution (node:internal/modules/esm/resolve:328:11)
    at moduleResolve (node:internal/modules/esm/resolve:983:10)
    at defaultResolve (node:internal/modules/esm/resolve:1195:11)
    at ModuleLoader.defaultResolve (node:internal/modules/esm/loader:403:12)
    at ModuleLoader.resolve (node:internal/modules/esm/loader:372:25)
    at ModuleLoader.getModuleJob (node:internal/modules/esm/loader:249:38)
    at ModuleWrap.<anonymous> (node:internal/modules/esm/module_job:76:39) {
  code: 'ERR_UNSUPPORTED_DIR_IMPORT'
}

Note: since Node.js 20, the error message often includes a helpful hint — "Did you mean to import ./utils/index.js?" — pointing directly at the fix.

Diagnostic Fields

PropertyValueMeaning
error.code'ERR_UNSUPPORTED_DIR_IMPORT'The Node.js error code
error.urldirectory path as a file:// URLThe path that triggered the resolution failure
error.messagefull string including directory path and importing fileContains both the bad import and its source location

All Causes at a Glance

CauseTriggerFix
Bare directory import (no extension) import './utils' or import '../lib' in an ESM file Add /index.js: import './utils/index.js'
Extension-less import of a named directory import { foo } from './models' in .mjs or "type":"module" project Change to import { foo } from './models/index.js'
CJS require()-style habit in ESM Migrating from CJS; forgot ESM rules for specifiers Add explicit file extension to every local import
TypeScript emitting extension-less imports tsc compiles import './utils' to the same string — no extension added Set moduleResolution: "node16" or "nodenext"; write .js extensions in source
Third-party package ships a directory import A published package's internal import resolves to a directory under ESM Pin to a fixed version or report the bug; use patch-package as a temporary fix
Bundler output uses directory imports Webpack/Rollup/esbuild configured to emit ESM with bare directory paths Configure the bundler to emit explicit extensions (e.g. Rollup preserveModulesRoot + entryFileNames)
Stale --experimental-specifier-resolution=node advice Running Node 19+ with this flag set in scripts or .npmrc Remove the flag; it was removed in Node 19 — update imports instead
Stale advice warning — do not use --experimental-specifier-resolution=node: Many Stack Overflow answers, blog posts, and npm issue threads from 2020–2022 recommend running node --experimental-specifier-resolution=node app.js to restore CJS-style directory and extension-less import resolution. This flag was removed in Node.js 19 (released October 2022). On Node 19 or later it produces node: bad option: --experimental-specifier-resolution and your process will not start at all. Update your import specifiers instead.

Cause 1 – Bare Directory Import in ESM Source

The most common cause: a developer writes an import the CommonJS way, expecting Node.js to find ./utils/index.js automatically.

CommonJS (works)

// src/app.cjs  — CommonJS, require() resolves directories automatically
const { validate } = require('./utils');   // resolves to ./utils/index.js  ✓
const { connect } = require('./db');       // resolves to ./db/index.js      ✓

ESM (broken)

// src/app.js  — ESM ("type":"module" in package.json or .mjs extension)
import { validate } from './utils';   // ✗ ERR_UNSUPPORTED_DIR_IMPORT
import { connect } from './db';       // ✗ ERR_UNSUPPORTED_DIR_IMPORT

ESM (fixed)

// src/app.js
import { validate } from './utils/index.js';   // ✓
import { connect } from './db/index.js';       // ✓
Tip: Use grep -r "from '\./[^']*'" src/ (Linux/macOS) or findstr /s /r "from '\./[^.']'" src\ (Windows) to find all bare directory imports in your source tree at once.

Cause 2 – TypeScript Emitting Extension-Less Imports

TypeScript's compiler does not add file extensions to import specifiers by default. When TypeScript compiles import { foo } from './utils', the output JavaScript contains the same string — no .js is inserted. This works under CommonJS-style resolution but fails under strict ESM resolution.

Broken TypeScript setup (default)

// tsconfig.json — old-style, works with tsc+CJS but breaks at ESM runtime
{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es2020"
  }
}

// src/app.ts
import { validate } from './utils';   // TypeScript allows this
// tsc emits: import { validate } from './utils';  ← no .js added → runtime error

Fixed TypeScript setup

// tsconfig.json — correct for Node.js ESM
{
  "compilerOptions": {
    "module": "node16",          // or "nodenext"
    "moduleResolution": "node16",// or "nodenext"
    "target": "es2022",
    "outDir": "./dist"
  }
}

// src/app.ts — you MUST write .js in the source even for .ts files:
import { validate } from './utils/index.js';   // TypeScript resolves ./utils/index.ts
                                                // tsc emits ./utils/index.js  ✓
Why write .js in TypeScript source? TypeScript with moduleResolution: "node16" follows the ESM specification: the extension you write is the extension in the emitted JS. TypeScript knows that when you write ./utils/index.js it should look for ./utils/index.ts at compile time and emit ./utils/index.js at runtime. It does not rewrite .ts to .js for you.

TypeScript path aliases and directory imports

// tsconfig.json — path aliases
{
  "compilerOptions": {
    "module": "node16",
    "moduleResolution": "node16",
    "baseUrl": ".",
    "paths": {
      "@utils/*": ["src/utils/*"]
    }
  }
}

// src/app.ts — still need explicit file in the aliased path:
import { validate } from '@utils/validate.js';   // ✓
// import { validate } from '@utils';             // ✗ still a directory import

Cause 3 – CJS-to-ESM Migration Patterns

When converting a project from CommonJS to ESM, every local import (formerly require()) that targeted a directory must be updated. A systematic approach:

Step 1 — add "type": "module" to package.json

// package.json
{
  "name": "my-project",
  "type": "module",          // ← enables ESM for all .js files
  "main": "./src/index.js"
}

Step 2 — update all local imports to use explicit extensions

// Before migration (CJS)
const { db } = require('./db');
const router = require('./routes');
const { config } = require('./config');

// After migration (ESM)
import { db } from './db/index.js';
import router from './routes/index.js';
import { config } from './config/index.js';

Step 3 — update re-export barrel files

// src/utils/index.js  — barrel file re-exporting from sub-modules
// Before (CJS)
module.exports = {
  ...require('./validate'),
  ...require('./format'),
};

// After (ESM)
export { validate } from './validate.js';
export { format } from './format.js';

Automated migration helper

# Use codemod tools to add extensions in bulk:
# Option 1 — TS-first projects
npx ts-add-js-extension --dir=src

# Option 2 — pure JS, uses AST transforms
npx @es-toolkit/cjs-to-esm src/

# Option 3 — manual grep and sed (Linux/macOS):
find src -name "*.js" | xargs sed -i \
  "s|from '\(\./[^']*\)'|from '\1.js'|g"
# Always review changes — this regex is not exhaustive

Cause 4 – Third-Party Package with Directory Import

Occasionally the error comes from inside node_modules, meaning a published package has a bug in its internal imports. The stack trace will show a file path inside node_modules/<package-name>.

// Stack trace identifying the culprit:
Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import
'/project/node_modules/some-pkg/lib/utils' is not supported
resolving ES modules imported from
/project/node_modules/some-pkg/src/index.js

// Workarounds:
// 1. Pin to the last version that did not have this bug:
npm install some-pkg@1.2.3

// 2. Use patch-package to add /index.js to the import inside node_modules:
npm install --save-dev patch-package
// edit node_modules/some-pkg/src/index.js, fix the import, then:
npx patch-package some-pkg

// 3. File a bug report with the package maintainer (preferred long-term fix)

Fix – Use a package.json Exports Map for Clean Import Names

If you want callers to write short import paths like import { foo } from 'my-lib/utils' without hitting ERR_UNSUPPORTED_DIR_IMPORT, define an exports map in your package's package.json. Node.js resolves exports entries before any file-system lookup, so the directory ambiguity never arises.

Without exports map (breaks under ESM)

// Consumer code — fails when my-lib is an ESM package:
import { validate } from 'my-lib/utils';   // ✗ tries to import the 'utils' directory

Library package.json with exports map

// my-lib/package.json
{
  "name": "my-lib",
  "version": "2.0.0",
  "type": "module",
  "exports": {
    ".": "./src/index.js",
    "./utils": "./src/utils/index.js",
    "./db": "./src/db/index.js",
    "./config": "./src/config/index.js"
  }
}

// Consumer code — now works:
import { validate } from 'my-lib/utils';   // ✓ Node resolves via exports map
import { connect } from 'my-lib/db';       // ✓

Dual CJS + ESM package with exports map

// package.json — ships both CJS and ESM
{
  "name": "my-lib",
  "version": "2.0.0",
  "main": "./dist/cjs/index.js",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js",
      "default": "./dist/cjs/index.js"
    },
    "./utils": {
      "import": "./dist/esm/utils/index.js",
      "require": "./dist/cjs/utils/index.js"
    }
  }
}

Custom Loader Hook (Last Resort)

If you cannot edit every import in a large legacy codebase, a custom ESM loader hook can intercept extension-less and directory-style specifiers and rewrite them before Node.js attempts resolution. This is an advanced escape hatch — prefer fixing imports directly.

// loader.mjs
import { stat } from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';

export async function resolve(specifier, context, nextResolve) {
  // Only handle relative specifiers
  if (!specifier.startsWith('.')) return nextResolve(specifier, context);

  const parentDir = path.dirname(fileURLToPath(context.parentURL));
  const resolved = path.resolve(parentDir, specifier);

  // If the path is a directory, append /index.js
  try {
    const s = await stat(resolved);
    if (s.isDirectory()) {
      return nextResolve(
        pathToFileURL(path.join(resolved, 'index.js')).href,
        context
      );
    }
  } catch { /* not a directory — let normal resolution proceed */ }

  return nextResolve(specifier, context);
}

// Run with:
// node --import ./loader.mjs app.js    (Node 20.6+)
// node --loader ./loader.mjs app.js   (Node 16–20, deprecated)
Loader hooks are not a long-term solution. The --loader flag is deprecated in favour of --import with register() (Node 20.6+). Custom loaders add startup overhead and can interfere with source-map generation and debuggers. Use them only while you work through a large migration.

Docker and CI Behaviour

A common pattern: the error appears in CI or a Docker container but not locally. This is nearly always a Node.js version mismatch. The --experimental-specifier-resolution=node flag was available in Node 12–18; if your Dockerfile uses a different base image than your local environment you may hit this mismatch.

# Check your Node version in Docker/CI:
node --version

# Common Dockerfile base images and their Node versions:
# node:18-alpine  → Node 18 (flag still present)
# node:20-alpine  → Node 20 (flag REMOVED)
# node:22-alpine  → Node 22 (flag REMOVED)

# Fix: update imports, not the Node version. Pin to a consistent Node version:
# .nvmrc
20.14.0

# Dockerfile
FROM node:20.14.0-alpine

Windows Specifics

The error behaves identically on Windows. However, Windows path separators in error messages use backslashes while import specifiers must always use forward slashes:

// Windows — WRONG (backslash in import specifier):
import { foo } from '.\\utils\\index.js';   // ✗ SyntaxError / resolution failure

// Windows — CORRECT (always forward slashes in import specifiers):
import { foo } from './utils/index.js';     // ✓

Safe Import Patterns for ESM Projects

// ---- PATTERNS THAT WORK IN NODE.JS ESM ----

// 1. Explicit relative file import with extension
import { helper } from './utils/index.js';
import { schema } from '../models/user.js';

// 2. Named package import (resolved via node_modules)
import express from 'express';
import { z } from 'zod';

// 3. Package subpath via exports map
import { validate } from 'my-lib/utils';   // only if package defines this in exports

// 4. Dynamic import of a directory entry (still needs explicit path)
const { default: routes } = await import('./routes/index.js');

// 5. import.meta.resolve() to check what a specifier resolves to (Node 20.6+)
const resolved = await import.meta.resolve('./utils/index.js');
console.log(resolved);  // file:///project/src/utils/index.js

// ---- PATTERNS THAT FAIL IN NODE.JS ESM ----

// Directory import — no extension, no filename
import { helper } from './utils';       // ✗ ERR_UNSUPPORTED_DIR_IMPORT

// Extension-less file import
import { schema } from '../models/user'; // ✗ ERR_MODULE_NOT_FOUND (different error)

// index shorthand
import setup from '.';                  // ✗ ERR_UNSUPPORTED_DIR_IMPORT

Debugging Checklist

  1. Read the error — the path after Directory import is the exact bad specifier, and imported from tells you which source file to edit.
  2. Open the source file and locate the import. It will be a path with no file extension that points to a directory containing an index.js.
  3. Add /index.js (or the correct filename) to the specifier.
  4. Check your Node.js version with node --version. If you are on Node 19+, remove any --experimental-specifier-resolution=node from your start scripts, package.json scripts, or .npmrc.
  5. If you use TypeScript, confirm moduleResolution in tsconfig.json is "node16" or "nodenext", and that your source imports already include .js extensions.
  6. Search the whole project for bare directory imports: grep -rP "from ['\"]\.\.?/[^'\"]+['\"]" src/ — any match without a file extension is suspect.
  7. If the error originates inside node_modules, check the package version and file a bug or pin to an older version.
  8. After fixing all imports, delete dist/ and rebuild, then re-run to confirm the error is gone.

Frequently Asked Questions

What is ERR_UNSUPPORTED_DIR_IMPORT?

ERR_UNSUPPORTED_DIR_IMPORT is thrown when an import statement in an ES module points to a directory rather than a specific file. Node.js ESM does not auto-resolve directories to their index.js file — that was a CommonJS convenience feature. The full error message reads: Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import '/project/src/utils' is not supported resolving ES modules imported from /project/src/app.js.

Why does this happen in ESM but not CommonJS?

CommonJS's require() added directory-to-index.js resolution as a convenience. The ES module specification does not include this behaviour — it requires every specifier to resolve to a specific file URL, matching how browsers handle module loading. Node.js follows the spec strictly to maintain cross-environment compatibility.

Can I use --experimental-specifier-resolution=node to fix this?

No, not on Node.js 19 or later. The --experimental-specifier-resolution=node flag was removed in Node 19 (October 2022). On any current Node.js version it produces node: bad option: --experimental-specifier-resolution. Many older articles still recommend this flag — do not follow that advice. Add explicit file extensions to your imports instead.

How do I fix ERR_UNSUPPORTED_DIR_IMPORT in TypeScript?

Set "moduleResolution": "node16" or "nodenext" in tsconfig.json. TypeScript will then enforce explicit file extensions in your import specifiers. You must write .js in the import path even though the source file is .ts — TypeScript resolves it to the .ts file at compile time and emits .js at runtime. Example: import { foo } from './utils/index.js' in a .ts file.

How do I fix ERR_UNSUPPORTED_DIR_IMPORT in ES Modules?

Add the explicit file name and .js extension to the import specifier. Change import { foo } from './utils' to import { foo } from './utils/index.js'. If the directory has a different entry file, point to that file directly. For packages you publish, add an exports map to package.json so callers can use short import paths without hitting a directory import.

Why does ERR_UNSUPPORTED_DIR_IMPORT happen in production but not locally?

The most common reason is a Node.js version difference between your local machine and the production/CI environment. If your local machine runs Node 18 and you were using --experimental-specifier-resolution=node, it worked locally. But production on Node 20+ rejects that flag. Another cause: your local environment is running files as CommonJS (no "type":"module" in package.json) while production has it set, or vice versa.

How do I use a package.json exports map to avoid directory imports?

Add an exports field to your package.json mapping the short import name to the full file path:

{
  "exports": {
    ".": "./src/index.js",
    "./utils": "./src/utils/index.js"
  }
}

Callers can then write import { foo } from 'my-package/utils' and Node.js resolves it without a directory import. The exports map takes priority over the file system.

What is the difference between ERR_UNSUPPORTED_DIR_IMPORT and ERR_MODULE_NOT_FOUND?

ERR_UNSUPPORTED_DIR_IMPORT means the specifier resolved to a directory — Node.js found the directory but refused to auto-pick a file from it. ERR_MODULE_NOT_FOUND means the specifier did not resolve to anything at all — no file or directory exists at that path. If you add /index.js and get ERR_MODULE_NOT_FOUND, the index.js file does not exist in that directory.

Related Errors