The Error
Error [ERR_UNSUPPORTED_DIR_IMPORT]: /app/src/utils is not supported resolving ES modules imported from /app/src/index.mjs. Did you mean to import /app/src/utils/index.js?
Classic migration pain. Something that worked perfectly in CommonJS now blows up the moment you switch to ES Modules. Or you added "type": "module" to your package.json, hit save, and watched half your imports break at once.
Why This Happens
CommonJS was forgiving. You could write require('./utils') and Node.js would silently resolve it to ./utils/index.js. That behavior is gone in ESM โ the spec requires fully explicit specifiers. Node.js will not guess the file for you.
So this import:
import { formatDate } from './utils';
...throws ERR_UNSUPPORTED_DIR_IMPORT because ./utils is a directory. Node.js sees a directory, refuses to pick an entry point, and stops dead.
Step-by-Step Fix
Option 1: Add the Explicit File Path (Quickest Fix)
Point the import at the actual file:
// Before (broken in ESM)
import { formatDate } from './utils';
// After (fixed)
import { formatDate } from './utils/index.js';
The .js extension is required โ even for TypeScript-compiled output. ESM does zero extension guessing.
Option 2: Use the exports Field in package.json (Clean, Scalable)
When utils is a well-defined module boundary, give it its own package.json with an exports map:
// utils/package.json
{
"name": "utils",
"type": "module",
"exports": {
".": "./index.js"
}
}
Now the original short import works again:
import { formatDate } from './utils';
Node.js reads utils/package.json, finds the exports map, and resolves to utils/index.js. No magic โ just explicit config.
Option 3: Create a Barrel Re-export File
Got 10+ files under utils/? Create an index.js that re-exports everything from one place:
// utils/index.js
export { formatDate } from './formatDate.js';
export { slugify } from './slugify.js';
export { parseEnv } from './parseEnv.js';
Then import from it explicitly:
import { formatDate, slugify } from './utils/index.js';
Clean public API, one import statement per consumer.
Option 4: TypeScript Users โ Set moduleResolution to node16 or bundler
Compiling TypeScript to ESM? Your tsconfig.json needs a resolution mode that matches ESM rules:
// tsconfig.json
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "node16",
"target": "ES2022"
}
}
With node16 or bundler, TypeScript flags directory imports at compile time. You catch the error before node does โ much better than a runtime crash in CI.
Find All Broken Imports Fast
Don't fix files one by one. Run grep across the whole project first:
# Find relative imports that look like directory paths (no extension)
grep -rn "from '\./[^']*[^.][^a-z]'" src/ --include="*.mjs" --include="*.js"
# Broader pattern โ catches both single and double quotes
grep -rEn "from ['\"]\./[^'\"]+[^/]['\"]" src/
You'll get a full list with file and line numbers. Fix them all in one pass instead of chasing errors one at a time.
Verify the Fix
Run your entry point directly:
node src/index.mjs
Error gone? Good. For a full project check, run the test suite:
node --experimental-vm-modules node_modules/.bin/jest
# or
npm test
To validate module resolution in isolation โ without running any app logic:
node --input-type=module <<< "import './src/utils/index.js'; console.log('OK');"
If it prints OK, resolution is clean.
Tips to Avoid This Going Forward
- Always write
.jsextensions in ESM imports โ even for.tssource files. TypeScript compiles them to.js, so that's what Node.js sees at runtime. - Enforce it with ESLint:
eslint-plugin-importwith"import/extensions": ["error", "always"]catches missing extensions before code review. - Migrating a large CJS codebase? Run a codemod first โ
cjs-to-esm-converteror@babel/plugin-transform-modules-commonjswill surface all the implicit resolutions you depended on without realizing it. - In monorepos, add an
exportsfield to every sub-package'spackage.json. It's extra upfront work, but it gives each package a clean, explicit API boundary โ which pays off the moment you have more than two consumers.

