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 Success | Runtime Failure |
|---|---|
| TypeScript compilation | Node.js runtime resolution |
| IDE type checking | CommonJS require() of ESM packages |
| Build process | Production deployment |
II. The Module System Constraint
Node.js supports two module systems with an asymmetric compatibility relationship:
| Module System | Syntax | Loading | ESM Import | CJS Import |
|---|---|---|---|---|
| CommonJS (CJS) | require(), module.exports | Synchronous | ❌ | ✅ |
| ES Modules (ESM) | import, export | Asynchronous | ✅ | ✅ |
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'becomesrequire('@tax-bridge/types') - If the package is ESM-only, Node.js throws
ERR_REQUIRE_ESM
The Compatibility Matrix
| Consumer | Package Format | Operation | Result |
|---|---|---|---|
| React (Vite) | ESM | import | ✅ Success |
| NestJS (Node) | CJS | import | ✅ Success (interop) |
| NestJS (Node) | CJS | require | ✅ Success |
| NestJS (Node) | ESM | require | ❌ CRASH |
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:
importstatement → routes toexports.importrequire()call → routes toexports.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:
- Explicit type mapping for robust TypeScript resolution
- Separate type files (
.d.mtsfor ESM,.d.tsfor CJS) for strict module separation - Named export compatibility across VS Code and TypeScript
- 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.jsonexportsfield - 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.require→dist/index.js(CommonJS) - Vite →
exports.import→dist/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/typesfirst (dependency) - Builds
apps/apiandapps/portalin 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
-
Compilation validates syntax; runtime validates module loading
- TypeScript validates import/export syntax
- Node.js validates module compatibility at runtime
- Test with actual
require()/importin the target environment
-
Module system compatibility is asymmetric
- ESM can import CJS and ESM
- CJS can import CJS only
- Shared packages require dual-emit
-
The
exportsfield routes consumers- Modern Node.js uses
exportsfor consumer routing - Nested structure provides explicit type mapping
- Aliases bypass this routing
- Modern Node.js uses
-
Use library-oriented tooling
tscis for applicationstsup/esbuildis 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.