Node.js ESM Error

Error [ERR_MODULE_NOT_FOUND]: Cannot find module

error code: ERR_MODULE_NOT_FOUND  ·  loader: ESM (import)

Complete reference — what it means, every cause unique to ESM resolution, and how to fix it.

Quick Answer: Node.js throws Error [ERR_MODULE_NOT_FOUND]: Cannot find module './utils' imported from /app/index.js when the ESM loader cannot resolve an import specifier. The #1 cause is a missing .js extension in a relative import — ESM never adds extensions automatically. Fix: change import { x } from './utils' to import { x } from './utils.js'. In TypeScript, set "module": "Node16" and "moduleResolution": "Node16" in tsconfig.json so the compiler enforces this for you.

What is ERR_MODULE_NOT_FOUND?

ERR_MODULE_NOT_FOUND is thrown exclusively by Node.js's native ES module (ESM) loader when an import statement or import() call cannot be resolved to a file or package. It is the ESM counterpart to the CommonJS MODULE_NOT_FOUND code.

The critical difference: the ESM loader is strict. It does not automatically try adding .js, look for /index.js in directories, or fall back to other extensions. Every specifier must exactly name a resolvable file.

Exact error messages you will see:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module './utils' imported from /app/index.js
Error [ERR_MODULE_NOT_FOUND]: Cannot find module './helpers/format' imported from /app/src/index.js
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'some-package' imported from /app/index.js
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/dist/utils' imported from /app/dist/index.js

Full Error Example

node:internal/modules/esm/resolve:838
  throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base), null);
  ^

Error [ERR_MODULE_NOT_FOUND]: Cannot find module './utils' imported from /app/index.js
Did you mean to import ./utils.js?
    at packageResolve (node:internal/modules/esm/resolve:838:9)
    at moduleResolve (node:internal/modules/esm/resolve:907:20)
    at defaultResolve (node:internal/modules/esm/resolve:1038:11)
    at ModuleLoader.defaultResolve (node:internal/modules/esm/loader:557:12)
    at ModuleLoader.resolve (node:internal/modules/esm/loader:530:38)
    at ModuleLoader.getModuleJob (node:internal/modules/esm/loader:246:38)
    at ModuleWrap.<anonymous> (node:internal/modules/esm/module_job:87:39) {
  code: 'ERR_MODULE_NOT_FOUND',
  url: 'file:///app/utils'
}

Since Node.js 18, the error message sometimes includes a hint: "Did you mean to import ./utils.js?" — a strong signal that a missing extension is the root cause. The error object also carries a url field containing the unresolved specifier as a file:// URL.

ERR_MODULE_NOT_FOUND vs MODULE_NOT_FOUND

PropertyERR_MODULE_NOT_FOUND (ESM)MODULE_NOT_FOUND (CJS)
Loader Native ESM (import) CommonJS (require())
Triggered by import statements, import() require() calls
Message format Cannot find module '...' imported from /path/to/file.js Cannot find module '...' + requireStack
Automatic extension resolution No — specifier must be exact Yes — tries .js, .json, .node
Automatic index.js lookup No — directory imports are forbidden Yes — require('./dir') tries ./dir/index.js
When it fires Package has "type":"module" or file is .mjs File is .js without "type":"module" or is .cjs

All Causes at a Glance

CauseTypical symptomQuick fix
Missing .js extension in relative import Cannot find module './utils' — file utils.js exists Change to import ... from './utils.js'
TypeScript source — wrong moduleResolution Compiled output has bare imports; fails at runtime Set module + moduleResolution: Node16 in tsconfig
Missing "type":"module" in package.json ESM syntax in .js → CJS loader → different errors Add "type":"module" to package.json
Directory import (no index.js auto-resolution) Cannot find module './models' — directory exists Import ./models/index.js explicitly
Package not installed Cannot find package 'express' npm install express
Missing package exports map entry Cannot find module 'pkg/internal' Use a public export path or createRequire
CJS-only package without ESM interop Cannot find package 'legacy-pkg' Use createRequire(import.meta.url)
Case sensitivity on Linux/CI Works on macOS/Windows, fails on Linux Match filename case exactly in import path
ts-node without ESM mode Fails with extensions at compile time or at runtime after tsc Use tsx or configure ts-node ESM mode
Wrong relative path level Cannot find module '../utils' when it's '../../utils' Verify path relative to current file, not project root

Cause 1 – Missing .js Extension in Relative Import

This is the single most common cause. The ESM specification requires that relative specifiers include the full file extension — there is no fallback resolution. Node.js will not try ./utils.js just because ./utils was requested.

CommonJS vs ESM resolution side by side

// CommonJS (require) — extension is optional
const utils = require('./utils');   // Node.js tries utils.js, utils.json, utils/index.js
const utils = require('./utils.js'); // also works

// ESM (import) — extension is MANDATORY for relative specifiers
import { helper } from './utils';    // ERR_MODULE_NOT_FOUND: Cannot find module './utils'
import { helper } from './utils.js'; // correct

Common patterns that need updating

// Before (worked in CJS, fails in ESM)
import { formatDate } from './helpers/format';
import config from '../config';
import * as db from './db/index';

// After (ESM-compliant)
import { formatDate } from './helpers/format.js';
import config from '../config.js';
import * as db from './db/index.js';
JSON imports also need an extension and an import assertion (Node.js 18+):
import data from './data.json' with { type: 'json' };
Without the with { type: 'json' } assertion, Node.js throws ERR_IMPORT_ASSERTION_TYPE_MISSING in some versions.

Cause 2 – TypeScript: Wrong moduleResolution Setting

This is the #1 TypeScript ESM pain point. TypeScript does not rewrite import paths during compilation — whatever you write in the .ts source is emitted verbatim into the compiled .js output. If you wrote import { x } from './utils' (no extension), the compiled output also has no extension, and Node.js's ESM loader throws ERR_MODULE_NOT_FOUND.

The three moduleResolution options and what they mean for ESM

moduleResolutionEnforces .js extension?Use withNotes
node (legacy) No — TypeScript won't warn about missing extensions CJS projects only Compiled output fails at runtime in ESM mode
node16 / nodenext Yes — TypeScript errors at compile time if extension is missing Node.js ESM (type: module) Mirrors actual Node.js ESM resolution rules exactly
bundler No — extensions are optional Vite, esbuild, webpack, tsup Bundler rewrites paths; do not use for Node.js ESM without a bundler

Correct tsconfig.json for Node.js ESM

// tsconfig.json — recommended for Node.js ESM projects
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",          // or "NodeNext"
    "moduleResolution": "Node16", // or "NodeNext" — must match module
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true
  }
}

TypeScript source file — use .js extension even though source is .ts

// src/index.ts
// Import uses .js extension — TypeScript resolves this to src/utils.ts at compile time
// The compiled dist/index.js will contain: import { helper } from './utils.js'
// which correctly references dist/utils.js at runtime

import { helper } from './utils.js';   // correct — .js refers to the compiled output
import { helper } from './utils.ts';   // wrong — TypeScript rejects this
import { helper } from './utils';      // wrong — ERR_MODULE_NOT_FOUND at runtime
TypeScript tip: With moduleResolution: "Node16", TypeScript will show a compile-time error "Relative import paths need explicit file extensions..." if you forget .js. This converts a runtime ERR_MODULE_NOT_FOUND into a compile-time type error — catch it before shipping.

Correct tsconfig.json for bundler-based projects (Vite, esbuild, tsup)

// tsconfig.json — for projects where a bundler handles resolution
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",  // bundler handles extensions, not Node.js
    "outDir": "./dist",
    "strict": true
  }
}

Cause 3 – Missing "type": "module" in package.json

Without "type": "module", Node.js treats .js files as CommonJS. An import statement in a CJS file throws SyntaxError: Cannot use import statement outside a module, not ERR_MODULE_NOT_FOUND. However, the missing field is still a common root cause that puts the whole project on the wrong loader path.

// package.json — required for ESM
{
  "name": "my-app",
  "version": "1.0.0",
  "type": "module",   // ← enables ESM for all .js files in this package
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Without "type": "module", use .mjs extension for individual ESM files instead of .js. The .mjs extension always forces ESM regardless of the type field.

// Option 1: use "type": "module" in package.json → all .js files are ESM
// Option 2: rename individual files to .mjs → those files are ESM
// Option 3: keep CJS and use dynamic import() for ESM packages

// Mixing .mjs and .cjs in one project
import { esm } from './helper.mjs';          // forced ESM
const cjs = require('./legacy.cjs');          // forced CJS (inside a .cjs file)
const esmPkg = await import('./utils.mjs');   // dynamic import from CJS file

Cause 4 – Directory Import (No Automatic index.js in ESM)

In CommonJS, require('./models') automatically resolves to ./models/index.js if models is a directory. The ESM loader does not do this — ERR_UNSUPPORTED_DIR_IMPORT is thrown instead (or ERR_MODULE_NOT_FOUND if the directory itself doesn't exist).

// Wrong — ESM does not resolve directories to index.js
import * as models from './models';
import * as models from './models/';

// Correct — explicit file path
import * as models from './models/index.js';

// Also correct — add a package.json inside the directory
// models/package.json:
// { "type": "module", "exports": { ".": "./index.js" } }
// Then: import * as models from './models'; works via exports map

Cause 5 – Package Not Installed

When importing a third-party package and the package is not installed, the ESM loader throws Cannot find package 'package-name'. This behaves identically to the CJS case.

# The error
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'express' imported from /app/index.js

# Fix
npm install express
# or
yarn add express
# or
pnpm add express
// After installing, the ESM import works normally
import express from 'express';
import { Router } from 'express';
Note on bare package specifiers: Node.js resolves bare specifiers (no ./ prefix) by looking in node_modules. For bare package specifiers in ESM, no file extension is required — the extension requirement only applies to relative (./, ../) and absolute (/) specifiers. import express from 'express' is always valid once the package is installed.

Cause 6 – Missing or Incomplete Package exports Map

Modern packages use the "exports" field in package.json to declare which subpaths can be imported. If you try to import a path not listed in exports, the ESM loader throws ERR_MODULE_NOT_FOUND (or ERR_PACKAGE_PATH_NOT_EXPORTED if the package has an exports map but the specific path is missing).

// Error: Cannot find package 'some-pkg/internal/utils' imported from /app/index.js
// because package.json exports does not include that path

// package.json (in node_modules/some-pkg)
{
  "exports": {
    ".": "./dist/index.js",
    "./helpers": "./dist/helpers.js"
    // "./internal/utils" is NOT listed — importing it throws ERR_MODULE_NOT_FOUND
  }
}

// Fix option 1: use a public export path
import { helper } from 'some-pkg/helpers';

// Fix option 2: if you control the package, add the export
{
  "exports": {
    ".": "./dist/index.js",
    "./helpers": "./dist/helpers.js",
    "./internal/utils": "./dist/internal/utils.js"  // added
  }
}

// Fix option 3: use createRequire to bypass the exports map (CJS packages only)
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const utils = require('some-pkg/internal/utils');

Cause 7 – CJS-only Package Imported in ESM Context

Some older or niche packages are CommonJS-only and have no ESM export in their package.json. Node.js's ESM loader can still load them — CJS packages are interoperable with ESM via the default export. If the error fires, the package is likely not installed, or its "main" field points to a non-existent file.

// CJS packages CAN be imported in ESM — no special handling needed in most cases
import lodash from 'lodash';           // works — lodash is CJS
import { merge } from 'lodash';        // also works (named imports from CJS default export)

// If the package has no ESM build AND its main field is broken, use createRequire
import { createRequire } from 'module';
const require = createRequire(import.meta.url);

const legacyPkg = require('legacy-cjs-only-package');
const { specificExport } = require('another-cjs-package/lib/specific');

Cause 8 – TypeScript with ts-node (CJS Hook Active)

By default, ts-node registers a CommonJS hook. When your project has "type": "module", ts-node may fail or fall back in unexpected ways. The compiled or interpreted output then runs through the ESM loader with bare specifiers that were fine under the CJS hook but fail under strict ESM.

Option A — Use tsx (recommended for development)

# Install tsx
npm install --save-dev tsx

# Run TypeScript ESM files directly — no tsconfig tweaks needed
npx tsx src/index.ts

# Or add to package.json scripts
{
  "scripts": {
    "dev": "tsx src/index.ts",
    "dev:watch": "tsx watch src/index.ts"
  }
}

Option B — Use node --experimental-strip-types (Node.js 22.6+)

# Node.js 22.6+ can strip TypeScript types natively (no compilation step)
node --experimental-strip-types src/index.ts

# Node.js 23.6+ — no flag needed
node src/index.ts

Option C — Configure ts-node for ESM mode

// tsconfig.json — ts-node ESM configuration
{
  "compilerOptions": {
    "module": "Node16",
    "moduleResolution": "Node16",
    "target": "ES2022"
  },
  "ts-node": {
    "esm": true,
    "experimentalSpecifierResolution": "node"  // only if needed for legacy imports
  }
}

// Run with:
// node --loader ts-node/esm src/index.ts
// or:
// ts-node-esm src/index.ts

Option D — Compile with tsc and run compiled output

# Build TypeScript to JavaScript
npx tsc

# Run the compiled output (Node.js reads dist/index.js with .js extensions intact)
node dist/index.js

Cause 9 – Case Sensitivity on Linux / CI

macOS (HFS+/APFS default) and Windows (NTFS default) are case-insensitive. Linux is case-sensitive. An import of ./Utils.js when the file is utils.js works locally but throws ERR_MODULE_NOT_FOUND in CI (usually Ubuntu) or in a Docker container (usually Alpine/Debian Linux).

// File on disk: src/utils.js

// Wrong — works on macOS/Windows, throws ERR_MODULE_NOT_FOUND on Linux
import { helper } from './Utils.js';
import { helper } from './UTILS.js';

// Correct — matches the actual filename on disk exactly
import { helper } from './utils.js';

Use eslint-plugin-import with the import/no-unresolved rule (or TypeScript's forceConsistentCasingInFileNames compiler option) to catch case mismatches at lint/compile time before they reach CI.

Safe ESM Import Patterns

Relative imports — always use .js

// src/server.ts (TypeScript, compiled to dist/server.js)
import { createApp } from './app.js';           // compiled: dist/app.js
import { db } from './db/connection.js';        // compiled: dist/db/connection.js
import { UserSchema } from './schemas/user.js'; // compiled: dist/schemas/user.js
import type { Config } from './types.js';       // type-only import, erased at compile time

ESM entry point pattern with package.json exports

// package.json
{
  "name": "my-library",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    },
    "./helpers": {
      "import": "./dist/helpers.js",
      "types": "./dist/helpers.d.ts"
    }
  },
  "main": "./dist/index.cjs",
  "module": "./dist/index.js"
}

ESM + CJS hybrid package (dual build)

// tsup.config.ts — build both ESM and CJS from TypeScript source
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['cjs', 'esm'],    // produce both .cjs and .js
  dts: true,                  // generate .d.ts files
  splitting: false,
  sourcemap: true,
  clean: true,
});

Using import.meta.url instead of __dirname in ESM

// In ESM, __dirname and __filename are not available
// Use import.meta.url instead

import { fileURLToPath } from 'url';
import { dirname, join } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// Now use __dirname as you would in CJS
const configPath = join(__dirname, '../config/default.json');

// Or use URL directly — often cleaner in ESM
const configURL = new URL('../config/default.json', import.meta.url);
import config from '../config/default.json' with { type: 'json' };

Dynamic import() for conditional loading

// Dynamic import works in both CJS and ESM files
// Useful for loading ESM-only packages from CJS, or for lazy loading

async function loadModule(name) {
  try {
    const mod = await import(name);
    return mod.default ?? mod;
  } catch (err) {
    if (err.code === 'ERR_MODULE_NOT_FOUND') {
      console.error(`Module '${name}' not found. Run: npm install ${name}`);
      process.exit(1);
    }
    throw err;
  }
}

// Example: load chalk (ESM-only) from a CJS file
const chalk = await loadModule('chalk');

Debugging Checklist

  1. Read the "imported from /path/to/file.js" part of the message — it tells you exactly which file has the bad import. Open that file and find the import statement.
  2. Check if Node.js is hinting: "Did you mean to import ./utils.js?" — if yes, add the .js extension immediately.
  3. Confirm the file actually exists on disk with the exact casing. On Linux/CI, case matters.
  4. Check package.json for "type": "module" — without it, .js files run as CJS.
  5. For TypeScript projects: check tsconfig.json — is module and moduleResolution set to Node16 or NodeNext?
  6. For TypeScript: is the compiled output in dist/ missing the .js extension in imports? If so, the tsconfig settings are wrong.
  7. Is it a package (not a relative file)? Run npm ls <package-name> to confirm it's installed locally.
  8. Is it a subpath import like pkg/lib/utils? Check if that path is listed in the package's "exports" field.
  9. Is it a directory import (./models pointing to a folder)? Rewrite as ./models/index.js.
  10. Run node --input-type=module <<< "import './your-file.js'" to test ESM resolution in isolation.

Frequently Asked Questions

What is ERR_MODULE_NOT_FOUND in Node.js?

ERR_MODULE_NOT_FOUND is thrown exclusively by Node.js's native ESM (import) loader when it cannot resolve a module specifier. It is the ESM counterpart to the CommonJS MODULE_NOT_FOUND code. The full message reads: Error [ERR_MODULE_NOT_FOUND]: Cannot find module './utils' imported from /app/index.js. Unlike the CJS loader, the ESM loader requires exact specifiers — it never adds .js or looks for /index.js automatically.

Why does ERR_MODULE_NOT_FOUND happen even when the file exists?

The file exists, but the import path is missing the .js extension. The ESM loader looks for a file literally named utils (no extension) and finds nothing. Change import { x } from './utils' to import { x } from './utils.js'. This is the #1 cause of this error.

How do I fix ERR_MODULE_NOT_FOUND in a TypeScript project?

Set "module": "Node16" and "moduleResolution": "Node16" in tsconfig.json. Then add .js extensions to all relative imports in your .ts source files:

// src/index.ts
import { helper } from './utils.js'; // .js = compiled output, not source

TypeScript will resolve ./utils.js to ./utils.ts at compile time. The emitted dist/index.js retains ./utils.js, which correctly references dist/utils.js at runtime.

Why does it work locally with ts-node but fail in production?

ts-node (without --esm) uses the CJS hook, which silently resolves missing extensions. In production, Node.js runs the compiled .js output through the strict ESM loader, which does not add extensions. The fix is to ensure all imports in .ts source files have .js extensions so the compiled output is correct, then compile with tsc and run the compiled output. Alternatively, switch the dev runner to tsx which behaves the same in both environments.

What is the difference between ERR_MODULE_NOT_FOUND and MODULE_NOT_FOUND?

MODULE_NOT_FOUND is thrown by require() (CommonJS loader). ERR_MODULE_NOT_FOUND is thrown by import (ESM loader). The ESM error message includes "imported from /path/to/file.js". The CJS error includes a requireStack array. The key behavioral difference: the CJS loader automatically tries .js, .json, /index.js; the ESM loader does none of this.

How do I use a CJS-only package inside an ESM project?

Most CJS packages work fine with a regular ESM import — Node.js handles CJS/ESM interop automatically. If you get ERR_MODULE_NOT_FOUND for a CJS package, it is usually not installed or its "main" field points to a missing file. For accessing internal CJS paths that bypass the exports map, use createRequire:

import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const internal = require('legacy-pkg/lib/internal');
Why does importing a directory cause ERR_MODULE_NOT_FOUND in ESM?

The ESM loader does not resolve directories to index.js — that is a CJS-only behavior. The proper ESM error for an existing directory is ERR_UNSUPPORTED_DIR_IMPORT, but if the directory itself does not exist you get ERR_MODULE_NOT_FOUND. Fix: always import the full file path including filename and extension: import './models/index.js' instead of import './models'.

How do I fix ERR_MODULE_NOT_FOUND in Docker or CI?

If the error is for a package (bare specifier), node_modules was not installed inside the container. Structure your Dockerfile correctly:

FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]

If the error is for a relative file (./utils), the ESM extension rule applies equally in Docker/CI — add .js to the import.

Should I use moduleResolution: bundler or nodenext for my TypeScript project?

Use moduleResolution: "NodeNext" (or "Node16") when your project runs directly on Node.js — this mirrors actual Node.js ESM resolution and enforces .js extensions at compile time, preventing runtime ERR_MODULE_NOT_FOUND. Use moduleResolution: "bundler" when a bundler (Vite, esbuild, tsup, webpack) processes your output — bundlers rewrite specifiers and do not need explicit extensions. Never use moduleResolution: "node" (legacy) for new ESM projects.

Related Errors