Blog · Engineering

The ESM/CommonJS Trap: Building Dual-Format Packages for Monorepos

· December 24, 2025 · 6 min read

How we discovered a critical runtime incompatibility in our shared TypeScript package and implemented a dual-emit strategy that works for both NestJS (CommonJS) and React (ESM).

Our NestJS API crashed on startup with ERR_REQUIRE_ESM in production. The culprit was a shared TypeScript package marked as ESM that our CommonJS backend couldn’t load. Here is how we diagnosed the architecture flaw and implemented a dual-emit strategy to resolve it.

I. The Gap Between Compilation and Runtime

We have a monorepo with pnpm workspaces. At the center is @tax-bridge/types, a shared package containing TypeScript definitions for tax methods, EFRIS codes, and billing status. Both our NestJS API and React Portal consume it.

The initial setup:

{
  "name": "@tax-bridge/types",
  "type": "module",
  "main": "./dist/index.js",
  "scripts": {
    "build": "tsc"
  }
}

Every validation step passed:

  • ✅ TypeScript compilation: Passed
  • ✅ IDE autocomplete: Working perfectly
  • ✅ React (Vite client): Functional
  • ✅ NestJS build: Compiled successfully

Production deployment revealed the issue:

Error [ERR_REQUIRE_ESM]: require() of ES Module
/packages/types/dist/index.js not supported.
Instead change the require of index.js to a dynamic import()...

The root cause: TypeScript validates import/export syntax. It does not validate Node.js runtime module resolution. When NestJS compiled our TypeScript to CommonJS, it converted import { x } from '@tax-bridge/types' into require('@tax-bridge/types'). At runtime, Node.js detected the "type": "module" field in the package and rejected the synchronous require() call.

Validation SuccessRuntime Failure
TypeScript compilationNode.js runtime resolution
IDE type checkingCommonJS require() of ESM packages
Build processProduction deployment

II. The Module System Constraint

Node.js supports two module systems with an asymmetric compatibility relationship:

Module SystemSyntaxLoadingESM ImportCJS Import
CommonJS (CJS)require(), module.exportsSynchronous
ES Modules (ESM)import, exportAsynchronous

The constraint: CommonJS uses synchronous loading. ESM uses asynchronous loading. A synchronous require() call cannot load an asynchronous module. This is a Node.js platform characteristic.

The Practical Consequence

  • NestJS compiles TypeScript to CommonJS by default
  • At runtime, import { x } from '@tax-bridge/types' becomes require('@tax-bridge/types')
  • If the package is ESM-only, Node.js throws ERR_REQUIRE_ESM

The Compatibility Matrix

ConsumerPackage FormatOperationResult
React (Vite)ESMimport✅ Success
NestJS (Node)CJSimport✅ Success (interop)
NestJS (Node)CJSrequire✅ Success
NestJS (Node)ESMrequireCRASH

The "type": "module" field marks all .js files in the package as ESM. CommonJS consumers cannot load these files.

III. The Dual-Emit Solution

The solution is to publish both formats and let Node.js route consumers to the correct one.

tsup for Library Bundling

TypeScript’s compiler (tsc) emits one format at a time. Generating both CommonJS and ESM would require multiple tsconfig.json files, separate build steps, and complex output management.

tsup (powered by esbuild) is purpose-built for library bundling:

  • Generates both formats in a single build
  • Handles type definitions automatically
  • ~100x faster than tsc
  • Designed for libraries

The Routing Mechanism

The exports field in package.json functions as a router. When a consumer imports the package, Node.js evaluates:

  • import statement → routes to exports.import
  • require() call → routes to exports.require

This automatic routing makes dual-emit seamless for consumers.

IV. The Implementation

Step 1: Build Configuration

Use tsup with dual-emit configuration:

// packages/types/tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: [
    'src/index.ts',
    'src/billing/index.ts',
    'src/tax-methods/index.ts',
    'src/efris/index.ts',
  ],
  format: ['cjs', 'esm'],  // Dual emit
  dts: true,                // Generate type definitions
  splitting: true,          // Code splitting for shared helpers
  sourcemap: true,          // Critical for debugging
  clean: true,
  treeshake: true,
  outExtension({ format }) {
    return {
      js: format === 'cjs' ? '.js' : '.mjs',
    };
  },
});

Build output:

dist/
├── index.js          (CommonJS - for NestJS)
├── index.mjs         (ESM - for Vite)
├── index.d.ts        (CJS types)
├── index.d.mts       (ESM types)
├── billing/
│   ├── index.js
│   ├── index.mjs
│   ├── index.d.ts
│   └── index.d.mts
└── ...

Step 2: Package Manifest

The nested exports structure explicitly maps types and runtime files for each module system:

{
  "name": "@tax-bridge/types",
  "version": "0.0.1",
  "main": "./dist/index.js",      // Fallback for older tools
  "module": "./dist/index.mjs",    // Fallback for bundlers
  "types": "./dist/index.d.ts",    // Fallback for TypeScript
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.mts",  // ESM types
        "default": "./dist/index.mjs"    // ESM runtime
      },
      "require": {
        "types": "./dist/index.d.ts",    // CJS types
        "default": "./dist/index.js"     // CJS runtime
      }
    },
    "./billing": {
      "import": {
        "types": "./dist/billing/index.d.mts",
        "default": "./dist/billing/index.mjs"
      },
      "require": {
        "types": "./dist/billing/index.d.ts",
        "default": "./dist/billing/index.js"
      }
    }
    // ... repeat for other subpaths
  }
}

The nested structure provides:

  1. Explicit type mapping for robust TypeScript resolution
  2. Separate type files (.d.mts for ESM, .d.ts for CJS) for strict module separation
  3. Named export compatibility across VS Code and TypeScript
  4. NodeNext module resolution compliance

The .mjs extension ensures Node.js strictly interprets the file as a module, even when bundlers bypass package.json metadata.

Step 3: Remove the "type": "module" Field

The "type": "module" field forces all .js files to be ESM. For dual-emit, the package root should remain format-agnostic.

Before:

{
  "type": "module",  // ← Forces all .js to be ESM
  "main": "./dist/index.js"
}

After:

{
  // File extensions determine format
  "main": "./dist/index.js",    // CJS
  "module": "./dist/index.mjs"  // ESM
}

Step 4: Update Vite Configuration

Remove any hardcoded alias that bypasses the exports field:

// ❌ Hardcoded alias (bypasses exports routing)
export default defineConfig({
  resolve: {
    alias: {
      "@tax-bridge/types": path.resolve(__dirname, "../../packages/types/dist/index.js")
    }
  }
});

// ✅ Use package.json exports routing
export default defineConfig({
  plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
  // Let package.json exports field handle resolution
});

Hardcoded aliases:

  • Bypass Node.js module resolution
  • Ignore package.json exports field
  • Force a specific file regardless of the consumer’s module system

Step 5: TypeScript Configuration

Use NodeNext module resolution to validate the exports field:

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2021",
    "strict": true,
    "declaration": true,
    "declarationMap": true
  }
}

V. Monorepo Architecture

Monorepos amplify module system issues. Shared packages serve as dependencies for multiple consumers with different requirements.

The Architecture

  • One package (@tax-bridge/types)
  • Multiple consumers with different module systems:
    • NestJS requires CommonJS for runtime compatibility
    • React requires ESM for optimal tree-shaking

The Routing Solution

Dual-emit ensures each consumer receives the appropriate format:

  • NestJS → exports.requiredist/index.js (CommonJS)
  • Vite → exports.importdist/index.mjs (ESM)

CI/CD Integration

Use pnpm’s recursive build to respect the dependency graph:

# GitHub Actions
- name: Build Monorepo
  run: pnpm -r build

This configuration:

  • Builds packages/types first (dependency)
  • Builds apps/api and apps/portal in parallel (dependents)
  • Respects the topological dependency graph

VI. Verification

Test with actual require() and import statements in the target environment.

Test 1: NestJS Compatibility

node -e "const { BillingAlertStatus } = require('@tax-bridge/types'); 
         console.log('✅ CJS works:', BillingAlertStatus.HEALTHY)"
# Output: ✅ CJS works: HEALTHY

Test 2: Build Output Verification

cd packages/types
pnpm build
ls -la dist/

# Expected output:
# dist/
# ├── index.js        (CommonJS - module.exports)
# ├── index.d.ts      (CJS Types)
# ├── index.mjs       (ESM - export)
# ├── index.d.mts     (ESM Types)

Test 3: Vite SSR

Start the dev server:

cd apps/portal
pnpm dev
# Expected: Clean startup

Consumer Verification

NestJS (apps/api):

  • ✅ Build succeeds
  • ✅ Runtime: require() loads
  • ✅ Types resolve correctly
  • ✅ Clean startup

React/Vite (apps/portal):

  • ✅ Build succeeds
  • ✅ Client bundle: ESM format used
  • ✅ SSR: ESM format used
  • ✅ Tree-shaking: Functional

VII. Reference

Key Principles

  1. Compilation validates syntax; runtime validates module loading

    • TypeScript validates import/export syntax
    • Node.js validates module compatibility at runtime
    • Test with actual require()/import in the target environment
  2. Module system compatibility is asymmetric

    • ESM can import CJS and ESM
    • CJS can import CJS only
    • Shared packages require dual-emit
  3. The exports field routes consumers

    • Modern Node.js uses exports for consumer routing
    • Nested structure provides explicit type mapping
    • Aliases bypass this routing
  4. Use library-oriented tooling

    • tsc is for applications
    • tsup/esbuild is for libraries with dual-emit requirements

The Dual Package Hazard

⚠️ Warning: Packages with internal state (like a database connection singleton) present a risk. A misconfigured application may load both the CJS and ESM versions, creating two separate instances with separate state. Keep shared packages stateless (types, constants, helpers).

Applicability

Appropriate use cases:

  • Monorepos with mixed module systems
  • Packages consumed by Node.js and bundlers
  • Packages requiring tree-shaking in frontend consumers

Simpler alternatives exist for:

  • Pure ESM monorepos
  • Pure CommonJS monorepos
  • Single-consumer packages

Conclusion

The ESM/CommonJS incompatibility is a module system architecture constraint requiring a dual-emit solution. This approach enables shared packages to function in both CommonJS (NestJS) and ESM (Vite) environments with proper TypeScript type resolution and optimal tree-shaking.

In a monorepo with mixed module systems, shared packages require both formats. Dual-emit provides the foundation for a single TypeScript package that functions in both CommonJS and ESM environments.

Building a monorepo with shared packages? Check out the TaxBridge Documentation to see how we handle complex integrations.