← Study Notes
📦 Advanced Frontend · Module Systems

ESM vs CJS
moduleResolution & module

The three most confusing TypeScript/Node.js configuration topics explained from first principles. Understand what each setting actually controls and how they interact.

ES Modules CommonJS moduleResolution: node | bundler | nodenext module: esnext | nodenext tsconfig.json
Part 1 — ESM vs CJS

What are module systems?

JavaScript didn't have a built-in module system until ES2015. Before that, Node.js invented CommonJS (CJS) using require(). The ECMAScript spec later standardized ES Modules (ESM) using import/export. Today both co-exist — and understanding the difference is critical for Node.js, TypeScript, and bundlers.

ℹ️
The core conflict: CJS was designed for servers (synchronous, dynamic), ESM was designed for the browser (async, static). Node.js supports both but they don't mix without friction.

CommonJS (CJS) CJS

CommonJS is Node.js's original module system, introduced in 2009. It uses require() and module.exports. It was never part of the JavaScript language spec — it's a Node.js convention.

CJS Exporting
// Named exports
function add(a, b) { return a + b; }
const PI = 3.14159;

module.exports = { add, PI };

// Or one at a time
module.exports.add = function(a, b) { return a + b; };

// Default export (whole object)
module.exports = function greet(name) {
  return `Hello, ${name}`;
};
CJS Importing
// Named imports (destructure)
const { add, PI } = require('./math');

// Whole module
const math = require('./math');
math.add(1, 2);

// Dynamic — can be inside a function, if block, anywhere
if (condition) {
  const utils = require('./utils'); // WORKS in CJS
}

// Inline require with no caching
const value = require('./config').value;

CJS Key Characteristics

  • Synchronous: require() blocks execution until the file is loaded. This works fine on a server (filesystem is local), but not in a browser.
  • Dynamic: You can require() inside loops, conditionals, functions — anywhere.
  • Cached: The first require() executes the file; subsequent calls return the cached module.exports object.
  • No static analysis: Bundlers can't know at build time which exports you use → can't tree-shake CJS reliably.
  • Circular deps: Supported but tricky — you get a partial object if circularly required.

ES Modules (ESM) ESM

ES Modules are the official JavaScript standard (ES2015+). They use import and export keywords. Browsers support them natively. Node.js has supported them since v12 (stable in v14+).

ESM Exporting
// Named exports
export function add(a, b) { return a + b; }
export const PI = 3.14159;

// Export list
function add(a, b) { return a + b; }
const PI = 3.14159;
export { add, PI };

// Default export
export default function greet(name) {
  return `Hello, ${name}`;
}

// Re-export from another module
export { add } from './math.js';
ESM Importing
// Named imports
import { add, PI } from './math.js'; // .js extension required in Node ESM!

// Default import
import greet from './greet.js';

// Namespace import (like import * in TS)
import * as math from './math.js';

// Dynamic import (async, returns a Promise)
const { add } = await import('./math.js'); // works anywhere

// Side-effect only import
import './setup.js';

ESM Key Characteristics

  • Static: Imports must be at the top level — no dynamic paths at parse time. This enables tree-shaking and circular dependency detection.
  • Asynchronous: Modules are loaded asynchronously (important for browser performance).
  • Live bindings: Imported values are live references, not copies. If the exporter changes the value, your import sees the update.
  • Strict mode always on: ESM is always in strict mode — no implicit globals, no with, etc.
  • Top-level await: You can await at the module level without wrapping in an async function.
  • Own this: this at the top level is undefined (not global/window).

Side-by-Side Comparison

Feature CJS CommonJS ESM ES Modules
Syntax require() / module.exports import / export
Standard Node.js convention (non-standard) ECMAScript spec (TC39)
Loading Synchronous Asynchronous
Dynamic imports Yes — require() anywhere Async only — await import()
Static analysis No — impossible to know at compile time Yes — enables tree-shaking
Tree-shaking Not reliable Yes (bundlers)
Strict mode Opt-in ('use strict') Always on
Top-level await No Yes
__dirname / __filename Available Not available (use import.meta.url)
Browser native support No (needs bundler) Yes
Node.js support Default Supported since v12 (with .mjs or "type":"module")
File extension .js or .cjs .mjs or .js (when "type":"module")
Extension in import path Optional (Node.js resolves it) Required in Node.js ESM

Interop Problems

The biggest pain point: CJS and ESM don't mix cleanly. Here's exactly what works and what doesn't.

Works — ESM imports CJS
// ESM file importing a CJS package
import express from 'express';  // ✅ OK
import { Router } from 'express'; // ✅ OK

// CJS module.exports becomes the default export
// Named exports come from the object's keys
Fails — CJS requires ESM
// CJS file trying to require an ESM package
const chalk = require('chalk'); // ❌ ERR_REQUIRE_ESM
// chalk v5+ is ESM-only
// You CANNOT require() an ESM module

// Fix: use dynamic import (async)
const { default: chalk } = await import('chalk');
🚨
The "dual package hazard": Some packages ship both CJS and ESM versions. If your app loads both (e.g., via transitive deps), you can end up with two separate instances of the same module — breaking instanceof checks, singletons, and shared state.

ESM-only packages you can't require()

Popular packages that went ESM-only and break CJS codebases:

  • chalk v5+
  • node-fetch v3+
  • p-limit v4+
  • ora v6+
  • execa v6+
  • got v12+

Solutions: pin to last CJS version, use await import(), or convert your whole project to ESM.

Using __dirname in ESM

ESM Equivalent of __dirname / __filename
import { fileURLToPath } from 'url';
import { dirname } from 'path';

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

// Or using import.meta.dirname (Node 21.2+)
const dir = import.meta.dirname;

The "type" field in package.json

The "type" field tells Node.js how to interpret .js files in that package. This is the single most important field for controlling CJS vs ESM in Node.js.

"type": "commonjs" (default)
// package.json
{
  "type": "commonjs"  // or omit entirely
}

// .js files are treated as CJS
// require() works in .js files
// Must use .mjs for ESM files
"type": "module"
// package.json
{
  "type": "module"
}

// .js files are treated as ESM
// import/export work in .js files
// Must use .cjs for CJS files
File extension With "type":"commonjs" With "type":"module"
.jsCJSESM
.cjsAlways CJSAlways CJS
.mjsAlways ESMAlways ESM
.tsControlled by tsconfig module setting (not package.json)
💡
.cjs and .mjs always win. Regardless of the "type" field, .cjs is always CommonJS and .mjs is always ESM. Use these extensions to escape the ambient setting.

The "exports" field (modern package entry)

package.json Dual CJS+ESM package
{
  "name": "my-lib",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",   // used by ESM consumers
      "require": "./dist/index.cjs",  // used by CJS consumers
      "types": "./dist/index.d.ts"
    }
  },
  "main": "./dist/index.cjs",    // fallback for old Node / tools
  "module": "./dist/index.mjs"   // bundler hint (not an official field)
}

File Extensions: .js .mjs .cjs .ts .mts .cts

Extension Format Use when
.jsDepends on "type"Default — whatever the package ambient mode is
.mjsAlways ESMForce ESM in a CJS package
.cjsAlways CJSForce CJS in an ESM package
.tsTypeScript source — format from tsconfigNormal TS files
.mtsTypeScript → compiles to .mjsForce ESM TypeScript files
.ctsTypeScript → compiles to .cjsForce CJS TypeScript files
.d.tsType declarations onlyGenerated by TypeScript
.d.mtsESM type declarationsTypes for .mjs files
.d.ctsCJS type declarationsTypes for .cjs files
Part 2 — moduleResolution

What is module resolution?

Module resolution is the algorithm TypeScript (and Node.js) uses to turn an import string like './utils' or 'lodash' into an actual file on disk. The moduleResolution tsconfig option controls which algorithm TypeScript uses.

ℹ️
Two separate things: moduleResolution is about finding files. The module option (Part 3) is about what output format TypeScript emits. They're related but independent.
tsconfig.json Where to set it
{
  "compilerOptions": {
    "moduleResolution": "bundler"  // node | bundler | node16 | nodenext
  }
}

moduleResolution: "node" Classic

The old default. Mimics how Node.js resolves CommonJS modules. It's the resolution algorithm most projects used for years — but it has blind spots for modern ESM packages.

How it resolves imports

Resolution steps for import x from './utils'
1. Try: ./utils.ts
2. Try: ./utils.tsx
3. Try: ./utils.d.ts
4. Try: ./utils/index.ts
5. Try: ./utils/index.tsx
6. Try: ./utils/index.d.ts
Resolution steps for import x from 'lodash' (node_modules)
1. Look in node_modules/lodash
2. Read package.json → look at "main" field
3. Try index.ts, index.d.ts

// Does NOT read "exports" field in package.json
// Does NOT require .js extensions in import paths
⚠️
Problem: moduleResolution: "node" ignores the "exports" field in package.json. Modern packages that use exports for subpath exports or conditional exports won't resolve correctly.

Also: you can write import x from './utils' (no extension) and TypeScript won't complain — but this won't work in native Node.js ESM.

moduleResolution: "bundler" Modern

Added in TypeScript 5.0. Designed specifically for projects using a bundler (Vite, webpack, esbuild, Rollup, Parcel). This is the right choice for Next.js, React, Vue, SvelteKit — any project where a bundler processes your files.

What it does differently from node

  • ✅ Reads the "exports" field in package.json (supports subpath exports)
  • ✅ Allows extensionless imports (./utils instead of ./utils.js) — bundlers handle resolution
  • ✅ Allows importing .ts files directly (bundlers transpile them)
  • ❌ Does NOT enforce Node.js ESM rules (no extension requirement)
  • ❌ Cannot be used without module: "esnext" or "preserve"
tsconfig.json Typical bundler project (Next.js / Vite)
{
  "compilerOptions": {
    "module": "esnext",          // or "preserve"
    "moduleResolution": "bundler",
    "target": "es2022",
    "esModuleInterop": true,

    // bundler-specific: you don't need extensions
    "allowImportingTsExtensions": true,   // import './foo.ts'
    "noEmit": true                        // bundler does the emitting
  }
}
✅ OK with bundler resolution
// All of these work — bundler resolves them
import { foo } from './utils';       // no extension
import { foo } from './utils.ts';    // .ts extension
import { foo } from './utils.js';    // .js extension

// Subpath exports work
import { Button } from 'my-ui/components'; // reads "exports" field

moduleResolution: "node16" / "nodenext" Node.js ESM

Added in TypeScript 4.7. Mirrors how Node.js natively resolves ESM modules. Required when you're writing a Node.js package that uses ESM without a bundler.

The key enforcement: explicit file extensions

❌ Error with nodenext
// TypeScript errors — no extension
import { add } from './math';

// Error: Relative import paths need explicit
// file extensions in ECMAScript imports
// when '--moduleResolution' is 'node16'.
✅ Correct with nodenext
// Import the .js extension — even for .ts source files!
import { add } from './math.js';

// TypeScript knows math.js will exist after
// compiling math.ts → math.js
⚠️
Counterintuitive: With moduleResolution: "nodenext", you write import from './math.js' even though the source file is math.ts. TypeScript knows the compiled output will be math.js and accepts it.

Mixed CJS/ESM files with nodenext

nodenext File extension determines module format
// src/server.ts  → compiled to .js → format depends on package.json "type"
// src/server.mts → compiled to .mjs → always ESM
// src/legacy.cts → compiled to .cjs → always CJS

// In a .mts (ESM) file — must use .js extension
import { helper } from './helper.js';   // ✅

// In a .cts (CJS) file — can use require()
const { helper } = require('./helper'); // ✅

Comparing All Resolution Modes

Feature node bundler node16 / nodenext
Extension required in imports No No Yes (for ESM)
Reads package.json "exports" No Yes Yes
Reads package.json "main" Yes Yes Yes (CJS fallback)
Supports import './foo.ts' No Yes (with flag) No
Index file resolution (./dir./dir/index) Yes Yes No (ESM doesn't do this)
Works without a bundler (Node.js native) Yes (CJS only) No Yes
Best for Old Node.js CJS projects Next.js, Vite, webpack apps Node.js ESM packages/CLIs
TS version introduced v1 v5.0 v4.7
Part 3 — module: esnext vs nodenext

The module compiler option

The module option in tsconfig.json controls the output format TypeScript emits when it compiles your .ts files. It answers: "What module system should the compiled JavaScript use?"

ℹ️
module vs moduleResolution:
  • module = what output format do we compile to? (CJS? ESM?)
  • moduleResolution = how do we find files when resolving imports?
They must be compatible with each other (see the table below).

module: "commonjs"

TypeScript compiles all import/export to require()/module.exports. The classic Node.js setup.

Source (.ts)
import { readFile } from 'fs/promises';
import { helper } from './utils';

export function main() {
  helper();
}
Output — module: "commonjs"
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.main = void 0;
const promises_1 = require("fs/promises");
const utils_1 = require("./utils");
function main() { (0, utils_1.helper)(); }
exports.main = main;
  • Use with: moduleResolution: "node"
  • Best for: Node.js servers, AWS Lambda, old tooling that expects CJS
  • Enables: require(), __dirname, __filename, synchronous module loading

module: "esnext"

TypeScript preserves import/export syntax as-is, targeting the latest ECMAScript module spec. The bundler (Vite, webpack, esbuild) handles the rest.

Source (.ts)
import { readFile } from 'fs/promises';
import { helper } from './utils';

export function main() {
  helper();
}
Output — module: "esnext"
import { readFile } from 'fs/promises';
import { helper } from './utils';

export function main() {
  helper();
}
// Nearly identical — TypeScript just strips types
  • Use with: moduleResolution: "bundler" (for bundled apps)
  • Best for: React apps (Next.js, Vite), browser apps, any bundled project
  • Also includes: es2015, es2020, es2022 — these are essentially the same, just different ES edition targets. esnext means "latest supported"
  • Note: Don't run module: "esnext" output directly in Node.js without a bundler — Node needs extensions

module: "nodenext" / "node16"

The correct setting for Node.js native ESM (no bundler). It emits proper Node.js ESM or CJS depending on the file extension (.mts.mjs, .cts.cjs, .ts → depends on package.json "type").

Setting module: "nodenext" automatically sets moduleResolution: "nodenext" — they're linked.

tsconfig.json Node.js ESM package setup
{
  "compilerOptions": {
    "module": "nodenext",         // implies moduleResolution: "nodenext"
    "target": "es2022",
    "outDir": "./dist",
    "declaration": true,
    "strict": true
  },
  "include": ["src"]
}
package.json required alongside module: nodenext
{
  "type": "module",       // .js files are ESM
  "main": "./dist/index.js",
  "exports": {
    ".": "./dist/index.js"
  }
}

node16 vs nodenext

node16 is locked to Node.js 16 behavior. nodenext is the forward-looking alias that will track future Node.js changes. Prefer nodenext for new projects.

module: "preserve" TS 5.4+

The newest option (TypeScript 5.4). It preserves whatever module syntax the source file uses — if you wrote import, you get import; if you wrote require, you get require. Like esnext but stricter: it signals to TypeScript "I have a bundler, don't touch module syntax at all."

  • Use with: moduleResolution: "bundler"
  • Required when: allowImportingTsExtensions: true
  • The right choice for Next.js 15+, Vite 5+ projects
module value Output format Needs bundler? Use for
"commonjs"CJS require()NoNode.js servers, Lambda
"esnext" / "es2022"ESM importYesBrowser apps, bundled projects
"nodenext"ESM or CJS per fileNoNode.js native ESM packages
"preserve"As-is (no transform)YesNext.js 15+, Vite (modern)
Practical

Setup Recipes

Recipe 1: Next.js / React App (Vite)

tsconfig.json Next.js / Vite
{
  "compilerOptions": {
    "module": "preserve",          // or "esnext"
    "moduleResolution": "bundler",  // bundler handles file resolution
    "target": "es2022",
    "lib": ["es2022", "dom"],
    "jsx": "preserve",
    "strict": true,
    "esModuleInterop": true,
    "noEmit": true,                // vite/next does the emitting
    "allowImportingTsExtensions": true
  }
}

Recipe 2: Node.js Express Server (CJS)

tsconfig.json Node.js CJS server
{
  "compilerOptions": {
    "module": "commonjs",
    "moduleResolution": "node",
    "target": "es2022",
    "lib": ["es2022"],
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "declaration": true
  }
}

Recipe 3: Node.js CLI / Library (Native ESM)

tsconfig.json Node.js ESM package
{
  "compilerOptions": {
    "module": "nodenext",         // sets moduleResolution: nodenext implicitly
    "target": "es2022",
    "outDir": "./dist",
    "strict": true,
    "declaration": true,
    "declarationMap": true
  }
}
package.json alongside nodenext tsconfig
{
  "type": "module",
  "exports": { ".": "./dist/index.js" },
  "scripts": { "build": "tsc" }
}

And you must use .js extensions in your source imports:

src/index.ts with nodenext
import { helper } from './helper.js'; // .js even though source is .ts
import { readFile } from 'fs/promises'; // node built-ins: no extension

Recipe 4: Dual CJS+ESM Library (publish to npm)

package.json dual package
{
  "name": "my-lib",
  "version": "1.0.0",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/esm/index.d.ts",
        "default": "./dist/esm/index.js"
      },
      "require": {
        "types": "./dist/cjs/index.d.ts",
        "default": "./dist/cjs/index.js"
      }
    }
  }
}
// Build with two tsconfig files:
// tsconfig.esm.json → module: "nodenext", outDir: dist/esm
// tsconfig.cjs.json → module: "commonjs", outDir: dist/cjs

Common Errors & Fixes

1. ERR_REQUIRE_ESM

Error
Error [ERR_REQUIRE_ESM]:
require() of ES Module ./node_modules/chalk/source/index.js
not supported.

// You're doing:
const chalk = require('chalk'); // chalk v5 is ESM-only
Fix
// Option A: use dynamic import
const { default: chalk } = await import('chalk');

// Option B: pin to last CJS version
// npm install chalk@4

// Option C: convert your project to ESM
// package.json: "type": "module"
// tsconfig: module: "nodenext"

2. "Relative import paths need explicit file extensions"

Error (with nodenext)
// error TS2835: Relative import paths need explicit
// file extensions in ECMAScript imports when
// '--moduleResolution' is 'node16' or 'nodenext'.

import { foo } from './foo';
Fix
// Add .js extension (even for .ts source files)
import { foo } from './foo.js';

// OR: switch to "bundler" if you're using a bundler
// tsconfig: "moduleResolution": "bundler"

3. "Cannot use import statement in a module"

Error
SyntaxError: Cannot use import statement
in a module

// Happens when: compiled .js still has
// import/export BUT package.json has
// "type": "commonjs" (or no type field)
Fix
// Option A: add "type": "module" to package.json

// Option B: change tsconfig module to "commonjs"
{
  "module": "commonjs"
}

// Option C: rename output to .mjs

4. "Option 'moduleResolution: bundler' requires module: esnext or preserve"

Error
// error TS5109: Option '--moduleResolution'
// can only be used when '--module' is set to
// 'preserve', 'es2015', 'es2020', 'es2022',
// 'esnext', 'node16', or 'nodenext'.

"module": "commonjs",
"moduleResolution": "bundler"  // ❌
Fix
"module": "esnext",         // ✅
"moduleResolution": "bundler"

// or
"module": "preserve",        // ✅
"moduleResolution": "bundler"

5. Module not found / subpath export not resolving

Error (with moduleResolution: node)
// Cannot find module 'my-pkg/components'
// (package uses "exports" subpaths)
import { Button } from 'my-pkg/components';
Fix
// Switch to bundler or nodenext resolution
// which reads package.json "exports" field
{
  "moduleResolution": "bundler"  // ✅ reads "exports"
}

Decision Guide

Answer these questions to pick the right tsconfig settings.

Project type?
Browser app / Next.js / Vite / SvelteKit module: "esnext" or "preserve"
moduleResolution: "bundler"
Node.js server (no bundler, traditional CJS) module: "commonjs"
moduleResolution: "node"
Node.js package / CLI (modern ESM, no bundler) module: "nodenext"
(sets moduleResolution: "nodenext" automatically)
Need tree-shaking?
Yes (bundle size matters) Use ESM output: "esnext" / "preserve" / "nodenext"
No (server, all code runs) CJS is fine: "commonjs"
Using a bundler?
Yes (Vite, Next.js, webpack, esbuild, Rollup) moduleResolution: "bundler"
No (running tsc output directly in Node.js) moduleResolution: "node" (CJS) or "nodenext" (ESM)
Subpath exports?
Yes — using modern packages with "exports" in package.json Avoid "node" resolution — use "bundler" or "nodenext"

Quick compatibility matrix

Scenario module moduleResolution "type" in pkg.json
Next.js 14+"preserve""bundler"n/a (Next handles it)
Vite React app"esnext""bundler""module"
Express server"commonjs""node""commonjs" or omit
Node.js CLI (ESM)"nodenext""nodenext""module"
npm library (dual)Two buildsTwo tsconfigs"module"
Bun app"esnext""bundler""module"
AWS Lambda (CJS)"commonjs""node""commonjs"
AWS Lambda (ESM)"nodenext""nodenext""module"