DEV Community

Cover image for Module Resolution in 2026: bundler, node16, and Why Your Imports Break
Gabriel Anhaia
Gabriel Anhaia

Posted on

Module Resolution in 2026: bundler, node16, and Why Your Imports Break


You move a working app to a published package. The tsconfig stays
the same. The code compiles. You npm publish, install it in a
fresh project, and the import line you wrote a hundred times throws
on the first run.

Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'/app/node_modules/my-lib/dist/util' imported from
/app/node_modules/my-lib/dist/index.js
Did you mean to import "./util.js"?
Enter fullscreen mode Exit fullscreen mode

The import resolved on your machine. It resolved in CI. It fails the
moment a real Node ESM runtime tries to load it. The reason is one
tsconfig field most people copy from a template and never read:
moduleResolution.

It is the field that decides how import { x } from "./util" turns
into a file on disk. Get it wrong and the compiler validates paths
the runtime cannot find. Here is what each mode does, when you owe
the .js extension, and how package.json exports maps fit in.

What moduleResolution actually controls

moduleResolution answers one question: given a specifier string
like "./util" or "lodash" or "#internal/db", which file does
the compiler look for, and where.

It does not control what code TypeScript emits — that is
module. The two are coupled, but they are separate dials. You can
emit ESM and resolve like a bundler, or emit CommonJS and resolve
the strict Node way. Mixing them wrong is the root of most
"works locally, breaks on install" reports.

The modes you will meet in 2026:

  • node10 (was node) — legacy CommonJS resolution. Deprecated.
  • node16 / nodenext — models real Node behavior, ESM and CJS.
  • bundler — models how Vite, esbuild, webpack, and Bun resolve.

The old classic mode is gone. If you still have it, the compiler
errors on startup and tells you to pick a real one.

bundler: the forgiving one

moduleResolution: "bundler" exists because bundlers were already
resolving imports more loosely than Node, and TypeScript needed a
mode that matched them instead of fighting them.

Under bundler, you write extensionless imports and the compiler is
happy:

// resolves to ./util.ts at build time
import { parse } from "./util";

// reads package.json "exports", no extension needed
import { z } from "zod";
Enter fullscreen mode Exit fullscreen mode

It reads package.json exports maps, supports paths, and lets you
omit file extensions. That is exactly what Vite and esbuild do, so
the types match what ships.

The catch: bundler assumes a bundler runs later. The output is
never executed by Node directly. If you point bundler at a build
that Node loads raw, the extensionless imports the compiler accepted
become the ERR_MODULE_NOT_FOUND from the opening. The compiler was
never lying — it was modeling a step you skipped.

Use bundler for apps: anything Vite, Next, Bun, or esbuild builds
before it runs. It is the default when module is esnext or
preserve, and it is the right default for most application code.

node16 / nodenext: the strict one

node16 and nodenext model how Node itself resolves modules. In
2026 they behave the same way; nodenext just tracks the latest
Node semantics as they land. Pick one and treat them as one mode.

This is the mode that demands the .js extension.

// WRONG under node16 in an ESM package
import { parse } from "./util";

// RIGHT — the extension is mandatory
import { parse } from "./util.js";
Enter fullscreen mode Exit fullscreen mode

Read that twice if it looks wrong. You import ./util.js from a
file named util.ts. The extension refers to the emitted file,
not the source. TypeScript resolves ./util.js against util.ts
for type checking, then leaves the specifier untouched in the
output, so Node finds util.js at runtime.

It feels backwards the first time. It is correct: the import string
is what ships, and what ships must point at a real .js file,
because Node's ESM loader does no extension guessing.

The other thing node16 enforces: the file's module format is
decided by package.json "type" and the file extension, not by
your tsconfig.

// package.json
{
  "type": "module"
}
Enter fullscreen mode Exit fullscreen mode

With "type": "module", every .js is ESM and every .ts emits
ESM. A .cts file is always CommonJS; a .mts file is always ESM.
node16 reads those rules the way Node does and errors when you
break them — for example, using import.meta in a file that resolves
as CommonJS.

Use node16/nodenext for anything Node runs without a bundler:
published libraries, CLIs, backend services that node dist/index.js
straight from tsc output.

How exports maps change resolution

The exports field in package.json is the other half of the
story. Once a package has one, it is the only way in. Deep
imports that used to work stop working.

// my-lib/package.json
{
  "name": "my-lib",
  "type": "module",
  "exports": {
    ".": "./dist/index.js",
    "./parser": "./dist/parser.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

With that map, a consumer can do this:

import { main } from "my-lib";
import { parse } from "my-lib/parser";
Enter fullscreen mode Exit fullscreen mode

And cannot do this, even though the file is right there on disk:

// blocked by the exports map
import { parse } from "my-lib/dist/parser.js";
Enter fullscreen mode Exit fullscreen mode

node16 and bundler both respect exports maps. node10 does not,
which is why a dependency resolves under the old mode and 404s under
the new one. If you bump a consumer to node16 and a third-party
import breaks, the package added an exports map and your old
deep-path import was never meant to survive.

Conditional exports let one package serve both module systems:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The types condition must come first or some resolvers miss it. If
your published types resolve to nothing under node16, a misordered
exports map is the usual cause.

The decision table

Match the mode to what runs the output, not to what feels modern.

Project shape module moduleResolution .js ext?
Vite / Next / Bun app esnext / preserve bundler no
Published npm library nodenext nodenext yes
Node backend, no bundler nodenext nodenext yes
Node backend, tsx/esbuild esnext bundler no
Dual ESM + CJS package nodenext nodenext yes
Monorepo internal package esnext bundler no

Two rules sit under the whole table:

  1. If Node loads your output directly, you owe the .js extension and you want nodenext.
  2. If a bundler stands between your output and the runtime, drop bundler in and skip the extensions.

The trap in the opening was an app tsconfig (bundler, no
extensions) shipped as a library that Node loads raw. The fix is one
field, plus adding .js to the relative imports.

Migrating an existing project

If you are on node (now node10) and the compiler nags you,
move deliberately:

// tsconfig.json — library or raw-Node service
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext"
  }
}
Enter fullscreen mode Exit fullscreen mode

Then expect a wave of "relative import paths need explicit file
extensions" errors. That wave is the point — it lists every import
that would have failed at runtime. A codemod such as
ts-add-js-extension
rewrites them in one pass. Run it, build, and run the output under
Node before you trust the green check.

For an app, the move is the opposite direction and quieter:

{
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "bundler"
  }
}
Enter fullscreen mode Exit fullscreen mode

No extension churn, because the bundler resolves them. Confirm your
tsx or vite-node dev runner agrees with the production bundler;
when dev and build use different resolvers you get imports that pass
in one and fail in the other.

The one mental model that fixes it

Stop asking which mode is correct in the abstract. Ask one question:
after tsc runs, who loads the files?

A bundler loads them → bundler, no extensions. Node loads them raw
nodenext, .js extensions, and a package.json whose "type"
and exports you actually wrote on purpose. The import that breaks
on install is almost always an app config shipped where a library
config belonged.

The .js extension is not a bug or a TypeScript quirk to route
around. It is the runtime telling you, at compile time, exactly what
the file will be called when Node goes looking for it.


If this was useful

The full resolution story — nodenext vs bundler, dual ESM/CJS
publishing, exports maps that serve both module systems, and the
monorepo configs where this gets sharp — is the spine of TypeScript
in Production
, book 5 in The TypeScript Library. If the decision
table above is the kind of thing you keep relearning, the chapter on
build and module config covers it end to end.

The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.

  1. TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
  2. The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
  3. Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
  4. PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
  5. TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)