You’ve set "type": "module" in your package.json, updated your tsconfig.json, maybe even added .js extensions to your imports. You run node dist/index.js and get hit with this:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/project/dist/utils' imported from /project/dist/index.js
If this sounds familiar, you’re not alone. TypeScript issue #41887 has over 400 upvotes and hundreds of comments, all from developers stuck on the same error. The frustration is real, and the existing resources are scattered across GitHub threads, short blog posts, and official documentation that doesn’t start from the error message you’re staring at.
This article is the systematic diagnosis I wish existed when I first ran into this. You’ll learn what causes ERR_MODULE_NOT_FOUND in TypeScript ESM projects, how to identify which of the five common root causes applies to your situation, and how to fix it for good.
If you’ve been through my complete guide to migrating from CJS to ESM and still hit this wall, this is the targeted troubleshooting guide you’ll need.
What ERR_MODULE_NOT_FOUND Actually Means
Before jumping to solutions, it helps to understand what Node.js is telling you.
Honestly, I hate to admit it, but it took me way too long to get into the habit of doing this. Instead I’d always skim the first line or two of an error, instead of really reading it.
When Node loads an ES module, it uses a strict resolution algorithm. This is different from CommonJS, where require("./utils") happily searches for utils.js, utils/index.js, or even utils.json. The ESM resolver does none of that. It takes your import specifier literally. If you write import { something } from "./utils", Node looks for a file called exactly utils. No extension guessing. No index file fallback.
This is a deliberate design decision. The Node.js ESM documentation is explicit: mandatory file extensions make resolution predictable and compatible with how browsers resolve modules.
The same import that works perfectly under CommonJS might fail with ESM, not because your code is wrong, but because the resolution rules changed. ERR_MODULE_NOT_FOUND` is Node telling you: “I looked exactly where you told me to look, and nothing was there.”
The confusion multiplies when TypeScript enters the picture. TypeScript has its own module resolution, your bundler might have another, and Node has yet another. When these don’t align, you get the error.
As a sidenote, this is finally getting standardized in node version 25 and Typescript 6! Its still new, and can’t be fully relied on yet, but the module resolution when working with both Typescript and ESM, should become much better soon. This applies to named imports too, that you can define in package.json.
The Root Cause: TypeScript Doesn’t Rewrite Import Paths
Here’s the thing most articles gloss over. TypeScript’s compiler (tsc) does not modify your import paths during compilation. If you write import { helper } from "./helper" in your .ts file, the compiled .js output contains exactly the same import path: import { helper } from "./helper".
This is not a bug. The TypeScript team has stated clearly in issue #42151 that they will not add path rewriting to the compiler. Their reasoning: TypeScript is a type system, not a build tool. Rewriting paths would make TypeScript responsible for understanding your runtime environment, your bundler, and your deployment setup. That’s a scope they intentionally avoid.
And again, this is just going to get much simpler with more recent versions of Node.
The practical consequence: tsc compiles your helper.ts into helper.js, but the import statement still says "./helper" without the .js extension. Node.js ESM resolver looks for a file literally named helper (no extension), doesn’t find one, and throws ERR_MODULE_NOT_FOUND`.
Why You Write .js Extensions for .ts Files
This is the part that feels wrong until you understand the mental model. In your TypeScript source file, you write:
import { helper } from "./helper.js";
Yes, .js. Even though the source file is helper.ts.
This works because you’re not referencing the source file. You’re referencing the output file. TypeScript understands that ./helper.js maps to ./helper.ts during compilation, and Node finds ./helper.js in the compiled output. Both sides are satisfied.
Diagnosing Your Specific ERR_MODULE_NOT_FOUND
Not every ERR_MODULE_NOT_FOUND has the same cause. Here’s a diagnostic checklist. Work through it top to bottom, because each check builds on the previous one.
Check 1: Is Your Project Actually Running as ESM?
This sounds basic, but it catches more people than you’d expect. Node determines whether a file is ESM or CommonJS based on two things:
- The
"type"field in your nearestpackage.json - The file extension (
.mjsforces ESM,.cjsforces CommonJS)
Open your package.json and verify:
{
"type": "module"
}
If this field is missing, Node treats .js files as CommonJS by default. In this case, your ESM-style imports run through the CommonJS resolver, which is more lenient (see earlier), and you might not get ERR_MODULE_NOT_FOUND but a different, equally confusing error instead.
Also check: if you’re in a monorepo, the "type": "module" must be in the package.json closest to your compiled output files, not just the root.
Check 2: Are Your Imports Missing File Extensions?
This is the single most common cause. After confirming ESM mode, check every relative import in your TypeScript source files:
// This will fail in ESM
import { createUser } from "./services/user";
// This works
import { createUser } from "./services/user.js";
Every relative import needs an explicit .js extension. Yes, even though your source file is .ts. I covered the reasoning above, but it bears repeating: you’re referencing the compiled output path.
For index files, you need to be explicit too:
// Fails: no index resolution in ESM
import { db } from "./database";
// Works
import { db } from "./database/index.js";
Index files where a big reason I saw errors when first converting projects from CJS to ESM. Until you know and understand this, its really confusing that a path that seems correct, just doesn’t work. We can no longer use those “path imports”, but have to use explicit files.
Adding extensions across a large codebase can feel tedious. I’ve found a regex-based approach works well for bulk updates. In your editor, search for:
from ["'](\.[^"']+)(?<!\.js)["']
This finds relative imports missing .js extensions. It’s not perfect, but it catches the majority.
Check 3: Is Your tsconfig.json Configured for ESM?
Your tsconfig.json needs specific settings for ESM output. The two most important fields are module and moduleResolution:
{
"compilerOptions": {
"target": "ESNext",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src"
}
}
The module setting determines what module syntax TypeScript outputs. For ESM with Node.js, use "Node16" or "NodeNext".
The moduleResolution setting determines how TypeScript resolves imports during type checking. This is where things get tricky, and I’ll cover the specific options in the next section.
A common misconfiguration: setting module to "ESNext" while leaving moduleResolution on "node" (the legacy default). TypeScript won’t enforce file extensions during type checking, so your code compiles without errors. Then Node fails at runtime because the extensions are missing. You get a false sense of security from tsc succeeding.
Check 4: Are You Importing a CommonJS Package from ESM?
When your ESM code imports a CommonJS package, Node handles the interop automatically, but sometimes imperfectly. You might see ERR_MODULE_NOT_FOUND when the package’s export map doesn’t include an ESM entry point.
The typical symptom: the package works fine when your project is CommonJS, but breaks when you switch to ESM.
A developer I worked with, spent two days on this exact issue last year. His custom NodeJS project was mid-migration to ESM, and about a third of the errors were from CommonJS packages that didn’t expose proper ESM entry points. The fix was different for each package: some needed a default import change, some needed a version bump, and two needed to stay as require() via createRequire.
Check the failing package’s package.json for an "exports" field:
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
If there’s no "import" entry, the package doesn’t officially support ESM imports. Your options:
- Use
createRequirefromnode:moduleto load it as CommonJS - Check if a newer version adds ESM support
- Use a wrapper or shim
Check 5: Is Your Runtime Handling ESM Correctly?
If you’re not running compiled output directly with node, your runtime adds another layer of resolution. Each tool handles ESM differently.
ts-node: Requires explicit ESM configuration. Add to your tsconfig.json:
{
"ts-node": {
"esm": true
}
}
And run with the --esm flag or use the loader approach:
// tsnode.esm.js
import { register } from "node:module";
register("ts-node/esm", import.meta.url);
node --import="./tsnode.esm.js" src/index.ts
Note that ts-node has multiple open issues around ESM support. If you’re fighting ts-node, consider switching to tsx instead.
tsx: Handles ESM transparently. No special configuration needed:
npx tsx src/index.ts
tsx uses esbuild under the hood and resolves modules more leniently. This makes it great for development, but be aware that it might mask issues that surface when you run the compiled output with plain node.
Bun: Also handles ESM natively and resolves modules without requiring .js extensions in source files. Same caveat as tsx: the permissive resolution might hide problems.
The moduleResolution Setting That Matters Most
TypeScript offers several moduleResolution options, and picking the wrong one is a common source of ERR_MODULE_NOT_FOUND that only shows up at runtime, not at compile time.
Here’s a brief table that maps the options:
| Setting | File Extensions Required | ESM Support | Best For |
|---|---|---|---|
"node" (legacy) |
No | Partial | Legacy CJS projects |
"node16" |
Yes | Full | Node.js 16+ projects |
"nodenext" |
Yes | Full | Node.js (latest behavior) |
"bundler" |
No | Full | Bundled applications (Vite, webpack, etc.) |
"node" (legacy): This is the old default. It mimics how Nodes CommonJS resolution works. It does not enforce file extensions, which means TypeScript won’t warn you about missing .js extensions. Your code compiles fine, then fails at runtime. If you’re doing ESM, don’t use this.
"node16" and "nodenext": These mirror actual ESM resolution. TypeScript will error at compile time if you forget file extensions. This is what you want for Node based ESM projects. The compile-time enforcement catches ERR_MODULE_NOT_FOUND before you ever run the code.
I thought it worth mentioning, that the difference between node16 and nodenext is subtle: nodenext tracks the latest Node resolution behavior, so it might change between TypeScript versions. node16 is locked to NodeJS 16 behaviour. For most projects, either works. I personally prefer node16 for the predictability.
"bundler": Introduced in TypeScript 5.0 as a pragmatic middle ground. It supports ESM syntax without requiring file extensions, because bundlers like Vite, ESBuild and Webpack resolve modules themselves. If your code goes through a bundler this is the setting that avoids most of the ERR_MODULE_NOT_FOUND pain. It’s also what I use in my dayjob in our relatively large Typescript monorepo. Its worth noting that if you’re running the compiled output directly with node, the "bundler" option gives you the same false sense of security as "node".
Fixing ERR_MODULE_NOT_FOUND for Common Setups
Pure Node with tsc
This is the most common setup and the one most prone to this error. Here’s a working configuration:
package.json:
{
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"sourceMap": true,
"strict": true
},
"include": ["src/**/*"]
}
Source files: Every relative import must include .js:
import { createApp } from "./app.js";
import { config } from "./config/index.js";
ts-node / tsx Development
For development with ts-node, add the ESM configuration to your tsconfig:
{
"ts-node": {
"esm": true,
"experimentalSpecifierResolution": "node"
}
}
If ts-node continues to give you trouble, switch to tsx. I’ve seen multiple teams make this switch after fighting ts-node ESM issues for weeks. tsx just works, and the performance difference with esbuild is noticeable.
Monorepo / Workspace Imports
Monorepos add a layer of complexity. Each package needs its own package.json with "type": "module", and cross-package imports need to resolve through the package’s export map.
{
"name": "@myorg/shared",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
}
A common trap: imports between workspace packages use the package name (not relative paths), so the "exports" field in each package’s package.json must be correct. If you’re seeing ERR_MODULE_NOT_FOUND on a workspace import, check the "exports" map first.
Third-Party Package Resolution
When a third-party package causes ERR_MODULE_NOT_FOUND, you have limited control. Check these in order:
- Update the package: Newer versions often add ESM exports
- Check for a
-esmvariant: Some packages publish separate ESM-compatible versions - Use
createRequire: For stubborn CJS-only packages:
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const legacyPackage = require("legacy-package");
This approach works reliably but does mix ESM and CJS patterns. It’s a pragmatic escape hatch, not a long-term solution.
Preventing This Error in New Projects
If you’re starting a new TypeScript ESM project in 2026, here’s the configuration that avoids most of this pain:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
Do note that this is likely to change with the release of Typescript 6.0, and subsequently 7.0, as they are changing some behaviours in tsconfig.
The combination of module: "Node16" and moduleResolution: "Node16" means TypeScript enforces file extensions at compile time. You’ll never get an ERR_MODULE_NOT_FOUND at runtime that tsc didn’t already catch.
If you’re debugging a particularly stubborn case, TypeScript’s --traceResolution flag shows you exactly how the compiler resolves each import:
tsc --traceResolution 2>&1 | head -100
This outputs every resolution step, including where TypeScript looked and what it found. It’s verbose, but it’s the fastest way to understand why a specific import fails.
One more thing: if your project uses a bundler (Vite, webpack, esbuild) and you don’t run compiled output with node directly, consider moduleResolution: "bundler". It removes the file extension requirement entirely, because the bundler handles resolution. This avoids most of the ERR_MODULE_NOT_FOUND surface area. The trade-off is that your TypeScript source won’t work with plain node without bundling first, but if you’re always bundling anyway, that constraint doesn’t matter.
For more on how TypeScript declaration files interact with module resolution, or how to extend interfaces for third-party types, I’ve covered those topics separately.
Conclusion
ERR_MODULE_NOT_FOUND in TypeScript ESM projects is a symptom of the ecosystem’s ongoing transition from CommonJS to ES modules. The error exists because Node.js ESM resolution is strict by design, and TypeScript deliberately does not rewrite your import paths.
The diagnostic path is straightforward:
- Confirm your project is actually running as ESM (
"type": "module") - Add
.jsextensions to all relative imports - Set
moduleResolutionto"node16"or"nodenext"so TypeScript catches issues at compile time - Handle CommonJS package interop explicitly
- Configure your runtime (ts-node, tsx, or plain node) correctly
If you’re mid-migration from CommonJS to ESM and running into this error alongside other issues, I wrote a comprehensive guide on converting a TypeScript project from CJS to ESM that covers the full process. And if you’ve learned anything from debugging CI builds that work locally but fail remotely, the debugging methodology I documented applies directly to tracking down module resolution issues.
Understanding the “why” behind this error is what separates a quick fix from a lasting one. Once you see that TypeScript and Node.js have different responsibilities in the module resolution chain, the error stops being mysterious and starts being predictable.