Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 059 / 100

moduleResolution: bundler — patterns, pitfalls and fixes

The right setting for Vite and Next.js projects.

Expona Admin6 min read

Introduction

Over the past 58 days, we've covered a lot of ground in TypeScript configuration, but there's one setting that trips up more developers than I'd like to admit: moduleResolution: bundler. This setting tells TypeScript how to resolve your imports the way your bundler does, not the way Node.js does—and getting it right can save you hours of debugging mysterious "module not found" errors.

If you're building with Vite or Next.js, this is the setting you need to understand.

What moduleResolution Does

Before we talk about bundler specifically, let's step back. The moduleResolution option in your tsconfig.json tells TypeScript how to find and resolve module imports at compile time. It's essentially saying: "When I see import x from 'something', how should I go looking for that file?"

The most common values are:

  • node: Follows Node.js's algorithm (traditional CommonJS/ESM lookup)
  • bundler: Follows how bundlers like Webpack, Vite, and Next.js actually resolve modules
  • nodeNext: Node.js's newer algorithm, useful for libraries

Here's the practical difference: Node.js and bundlers don't always resolve the same way. A bundler might apply custom loaders, handle file extensions differently, or respect export conditions you've defined. If your moduleResolution setting doesn't match what your bundler actually does, TypeScript will give you false negatives—code that compiles but fails at runtime—or false positives—code that TypeScript rejects but your bundler would handle fine.

Matches How Bundlers Resolve Imports

When you set moduleResolution: bundler, you're telling TypeScript: "Resolve imports the way Vite, Webpack, or Next.js would, not the way Node.js would." This is critical because bundlers have their own resolution algorithms that are often more flexible than Node's.

Let me show you a real scenario. Suppose your project structure looks like this:

typescript
src/
  utils/
    helpers.ts
  App.tsx

In your App.tsx, you might write:

typescript
import { formatDate } from '@/utils/helpers';

That @ alias? It's not something Node.js understands natively. But Vite and Next.js do, because they read your tsconfig.json paths and apply them during bundling. If you use moduleResolution: node, TypeScript won't know how to resolve @/utils/helpers, and you'll get a compile error even though your bundler would handle it perfectly.

With moduleResolution: bundler, TypeScript respects path aliases, which bundlers honor. Here's the relevant part of a typical tsconfig.json

typescript
{
  "compilerOptions": {
    "moduleResolution": "bundler",
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"],
      "@hooks/*": ["src/hooks/*"]
    }
  }
}

Now when TypeScript sees import x from '@/utils/helpers', it knows to look in src/utils/helpers. Your bundler will do the same thing at runtime. They're in sync.

Supports Package Exports

Modern npm packages define their public API using the exports field in package.json. This is how maintainers control what you can import from a package. For example:

typescript
{
  "name": "my-library",
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils.js",
    "./internal/*": null
  }
}

This says: "You can import from the root or from ./utils, but you cannot import anything from ./internal." If someone tries import x from 'my-library/internal/secret', it should fail.

Here's the catch: moduleResolution: node doesn't fully respect exports in all cases, especially with conditional exports (ESM vs CommonJS). moduleResolution: bundler does. Bundlers understand and enforce the exports field because that's how they determine what's public.

I learned this the hard way on a recent project. We had a monorepo where an internal utility was leaked through the exports field accidentally. With moduleResolution: node, TypeScript didn't catch it. With bundler, it did—preventing a runtime error months later.

If you're consuming packages that use conditional exports—like packages with both ESM and CommonJS versions, or browser/Node variants—bundler is more reliable. Your bundler respects these conditions, and so should TypeScript.

Use NodeNext for Libraries Targeting Node

Here's where I need to be clear about scope: moduleResolution: bundler is for applicationsmoduleResolution: nodeNext

instead.

Why the distinction? Because when your library is published to npm and consumed by others, they might be running it on Node.js without a bundler. Or they might use it in both Node and browser environments. nodeNext aligns with Node.js's actual resolution algorithm, so your library works correctly in Node environments.

Here's a practical difference:

typescript
// library/tsconfig.json
{
  "compilerOptions": {
    "moduleResolution": "nodeNext",
    "module": "esnext",
    "target": "es2020"
  }
}

// This library is published and consumed like:
// import { parse } from 'my-library';
// node consumer.mjs  // Works correctly because moduleResolution: nodeNext

For an application—a Vite app, a Next.js project, a React Native app—you want bundler. The bundler is responsible for getting code to users, so TypeScript should validate against the bundler's resolution logic.

The heuristic I use: If your build output is a dist/ folder with bundled JavaScript, use bundler. If your build output is transpiled source files meant to be required or imported directly, use nodeNext.

Mismatched Settings Cause Confusing Import Errors

Let me walk you through a real problem I debugged last month. A developer on my team was working on a Vite project, but someone had left moduleResolution: node in the tsconfig. The code compiled fine locally—the bundler ran without issues. But TypeScript was unhappy about imports from a UI library that had conditional exports.

The error looked like:

typescript
Cannot find module '@ui-library' or its corresponding type declarations.

But the bundler was resolving it fine at runtime. The problem? TypeScript was using Node's resolution algorithm, which didn't understand the package's exports conditions. The bundler did understand them. Mismatch.

Once we changed to moduleResolution: bundler, the error vanished immediately. TypeScript now validated against the same rules as the bundler.

Here's another case: path aliases. If your tsconfig.json has:

typescript
{
  "compilerOptions": {
    "moduleResolution": "node",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

TypeScript might or might not resolve @/components/Button correctly, depending on the context. But Vite will always resolve it correctly, because Vite explicitly applies path mappings. The mismatch means you can't trust TypeScript's diagnostics.

Worse, you might ship code that TypeScript approves but fails at runtime. Or vice versa—TypeScript rejects code that would actually work.

Common Mistakes

I've seen these patterns repeatedly:

Mistake 1: Using moduleResolution: node in a Vite project. This is the most common one. Vite is not Node.js, and its resolution rules differ. If you're using Vite, set moduleResolution: bundler.

Mistake 2: Not realizing that moduleResolution: bundler is relatively new. It was added in TypeScript 4.7 (mid-2022). If you're maintaining a project that targets older TypeScript versions, you can't use it. But if you're on 4.8 or later, there's no reason not to.

Mistake 3: Mixing bundler with nodeNext. In a monorepo, you might have both app and library packages. Make sure each has the right setting. Apps use bundler, libraries use nodeNext.

Mistake 4: Not checking your bundler's docs for additional resolution rules. Vite and Next.js both have special resolution behavior beyond standard bundler behavior. If you're using advanced features (like Vite's glob imports), you might need additional setup in tsconfig.json.

Key Takeaways

  • moduleResolution: bundler tells TypeScript to resolve imports the way your bundler (Vite, Webpack, Next.js) does, not the way Node.js does.
  • It properly supports path aliases, package exports, and conditional exports—all things bundlers handle natively.
  • Use bundler for applications with a build step. Use nodeNext for libraries targeting Node.js directly.
  • Mismatched resolution settings cause false positives and false negatives—code TypeScript approves but bundlers reject, or vice versa.
  • If you're using Vite, Next.js, or any modern bundler, moduleResolution: bundler should be in your tsconfig.json.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles