Skip to main content

TypeScript Module Resolution Guide: Node16, NodeNext, or Bundler?

 One of the most frustrating experiences in modern TypeScript development is setting up a fresh Node.js project, configuring your tsconfig.json, and immediately hitting ERR_MODULE_NOT_FOUND or "Cannot find module" errors.

You check the file path. It exists. You check the export. It exists. Yet, Node.js refuses to run your code, or TypeScript screams about file extensions.

The root cause usually lies in a mismatch between how TypeScript thinks modules resolve and how the Node.js runtime actually resolves them. With the introduction of ECMAScript Modules (ESM) as a stable feature in Node.js 16+, the rules have changed. The old moduleResolution: "node" is no longer sufficient for modern backends.

This guide provides the definitive configuration for modern TypeScript environments, explains why NodeNext is critical for backend development, and clarifies when to use Bundler.

The Root Cause: Compile-Time vs. Runtime Resolution

To fix the configuration, we must understand the divergence between TypeScript and Node.js.

Historically, Node.js used CommonJS (CJS). If you wrote require('./utils'), Node would look for ./utils.js./utils.json, or ./utils/index.js. It was forgiving. TypeScript’s moduleResolution: "node" mimicked this behavior.

However, modern Node.js (v16+) running in ESM mode ("type": "module" in package.json) is strictly compliant with the ECMAScript specification.

The ESM Rules:

  1. Explicit Extensions: You cannot omit file extensions. import './utils' is invalid. You must write import './utils.js'.
  2. No Index Resolution: import './folder' does not automatically resolve to ./folder/index.js.
  3. Strict Pathing: Relative paths must start with ./ or ../.

The error occurs because TypeScript (at compile time) might understand your import without an extension, but when it compiles to JavaScript, it leaves the import path exactly as you wrote it. If you wrote import ... from './file', Node receives import ... from './file' and crashes because the file doesn't exist (only file.js exists).

The Solution: Configuring tsconfig.json for Node.js

If you are building a backend service, CLI tool, or library intended to run in Node.js, you should align your TypeScript configuration with the Node.js runtime capability.

1. The "Golden" Configuration for Node 16+

Update your tsconfig.json to enforce the rules that Node.js will apply at runtime.

{
  "compilerOptions": {
    /* Base Options */
    "target": "ES2022",
    "lib": ["ES2023"],
    "strict": true,
    
    /* Module Resolution */
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    
    /* Output */
    "outDir": "./dist",
    "sourceMap": true,
    
    /* Interop */
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

2. The package.json Requirement

For NodeNext to behave correctly as ESM, you must declare your package as a module.

{
  "name": "my-backend-service",
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=18.0.0"
  }
}

3. The Code Change: Relative Imports

This is the part that feels unnatural to many developers. When using moduleResolution: "NodeNext", TypeScript will force you to add file extensions to your relative imports.

Crucially, you must import the file that will exist at runtime (.js), not the source file (.ts).

Incorrect (Will throw error in NodeNext):

// src/index.ts
import { database } from "./db"; // Error: Relative import paths need explicit file extensions...

Correct:

// src/index.ts
// We import .js because that is what will exist in the ./dist folder
import { database } from "./db.js"; 
import { User } from "./models/User.js";

const startServer = async () => {
  console.log("Server starting...");
};

TypeScript knows that when you ask for ./db.js, you actually mean the definitions found in ./db.ts. It resolves the types correctly while ensuring the emitted JavaScript code works in Node.

Deep Dive: NodeNext vs. Bundler

A common source of confusion is the introduction of moduleResolution: "bundler" in TypeScript 5.0. Which one should you choose?

When to use NodeNext

Use NodeNext (or Node16) when your compiled code will be executed directly by Node.js (via node dist/index.js).

NodeNext enforces the strict rules of the runtime. It supports package.json "exports" fields, meaning it respects libraries that expose different entry points for CJS (require) and ESM (import). This is vital for backend stability.

When to use Bundler

Use bundler if your code will be processed by a tool like Vite, Webpack, ESBuild, or Next.js before being run.

Bundlers are much smarter than the Node.js runtime. They can handle extension-less imports (import App from './App'), non-code imports (import './style.css'), and path aliases without strict runtime mapping.

Recommended tsconfig.json for Vite/Next.js:

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "allowImportingTsExtensions": true, // Allows importing .ts directly
    "noEmit": true // Bundler handles emission, not tsc
  }
}

Handling package.json Exports

One of the most powerful features unlocked by moduleResolution: "NodeNext" is the ability to correctly type-check libraries using the exports field.

Modern libraries often hide their internal structure.

// node_modules/some-lib/package.json
{
  "exports": {
    ".": "./index.js",
    "./feature": "./feature.js"
  }
}

If you try to import some-lib/dist/internal-utils.jsNodeNext will throw an error because that path is not exposed in exports. This protects you from relying on private library internals that might break in minor updates. Old configurations (moduleResolution: "node") allowed these unsafe imports.

Common Pitfalls and Edge Cases

1. "I don't want to write .js in my .ts files!"

This is a common complaint. It feels dirty to reference a JavaScript file inside TypeScript.

If you strictly refuse to do this, you have two options:

  1. Use a Bundler: Compile your backend with esbuild or swc into a single file. You can then use moduleResolution: "bundler".
  2. Use tsx or ts-node: If you only run code in development or via a loader that compiles on the fly, you can sometimes get away with omitting extensions, but this often leads to breakage when you finally build for production.

Best Practice: Embrace the .js extension. It is the web standard. Deno uses it, browsers use it, and Node.js uses it.

2. Dual Package Hazard (CJS vs ESM)

If you are writing a library that supports both CommonJS and ESM, NodeNext is mandatory. It allows you to use .mts (compiled to .mjs for ESM) and .cts (compiled to .cjs for CommonJS) in the same project.

// utils.mts
export function add(a: number, b: number) { return a + b; }

TypeScript will automatically generate the correct definitions (.d.mts) ensuring that consumers of your library get the correct types depending on whether they import or require your package.

3. Absolute Path Aliases

If you use path aliases in tsconfig.json:

"paths": {
  "@utils/*": ["./src/utils/*"]
}

Remember that standard tsc does not rewrite these paths to relative paths during compilation. If you compile this, Node.js will crash because it doesn't know what @utils means.

To use aliases in a pure Node.js environment (without a bundler), you must use a runtime solution like tsc-alias or Node's Subpath Imports (#utils in package.json), which is supported by NodeNext.

Conclusion

The shift to ESM in Node.js has forced TypeScript to become stricter. While the errors are annoying at first, they prevent runtime crashes that used to plague production deployments.

  • Building a Node.js Backend / Library? Set moduleResolution: "NodeNext". Remember to add .js extensions to imports.
  • Building a Frontend App (Next.js/Vite)? Set moduleResolution: "Bundler".

By aligning your TypeScript config with your runtime reality, you eliminate ambiguity and ensure your application runs as predictably as it compiles.