Background
In my monorepo project, pawHaven, the frontend and backend are not completely isolated systems.
They naturally share a portion of code, including:
- Constants
- Configuration schemas
- Enums and dictionaries
- Pure utility functions
It seemed natural to extract these common pieces into a shared package and use it across both frontend and backend.
Initially, this architecture appeared almost trivial to implement.
With TypeScript, pnpm workspaces, and a monorepo already in place, it felt like everything was aligned.
When the Problems Started
The real issues did not appear while writing the shared code, but when running the applications.
After building the frontend and backend separately, I started encountering errors:
- Node reported
Unexpected token 'export' - Frontend builds succeeded but failed at runtime
- Some modules were reported as missing
- CommonJS could not handle ESM syntax
Although these errors seemed random, the underlying issue was clear:
Frontend and backend expect completely different module systems.
My Initial Wrong Assumption
I initially assumed it would be possible to produce a single build output compatible with both CommonJS and ESM.
I spent days experimenting with:
-
module: ESNext -
module: CommonJS -
"type": "module" - Different
moduleResolutionstrategies - Various
tsconfigcombinations
After nearly three days of trial and error, it became clear:
A single build output cannot satisfy both CommonJS and ESM.
These two targets are fundamentally incompatible.
The Key Shift in Thinking
The breakthrough came from a simple question:
Why must a shared package produce only one output?
Frontend and backend environments are inherently different:
| Environment | Module Expectation |
|---|---|
| Frontend (Vite/Webpack) | ESM |
| Node backend (Nest/require) | CommonJS |
Thus, the correct approach is to produce separate builds for each environment rather than compromise with a single artifact.
1. One Source of Truth
The shared package maintains a single source code base written in TypeScript using ESM syntax.
All code resides in a single src directory and uses standard export statements.
2. Two TypeScript Configurations, Two Targets
The package uses two separate TypeScript configurations:
packages/shared/
├─ tsconfig.esm.json
├─ tsconfig.cjs.json
- One configuration for ESM output targeting frontend and bundlers
- One configuration for CommonJS output targeting Node.js backend
This setup produces:
- An ESM build for frontend and bundlers
- A CommonJS build for Node.js backend
// tsconfig.cjs.json
{
"extends": "@pawhaven/tsconfig/base",
"compilerOptions": {
"outDir": "dist/cjs",
"module": "CommonJS",
"moduleResolution": "node"
},
"exclude": ["node_modules", "dist"]
}
// tsconfig.esm.json
{
"extends": "@pawhaven/tsconfig/base",
"compilerOptions": {
"outDir": "dist/esm",
"module": "ESNext",
"moduleResolution": "bundler"
},
"exclude": ["node_modules", "dist"]
}
3. Precise Entry Resolution via package.json
{
"name": "@pawhaven/shared",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
}
}

The package.json defines which build is loaded for different consumers:
-
mainpoints to the CommonJS build for Node.js (require) -
modulepoints to the ESM build for bundlers (import) -
typespoints to the TypeScript declaration files for type checking
The exports field ensures precise resolution:
| Usage | Field | Output |
|---|---|---|
import |
exports.import |
ESM build |
require |
exports.require |
CommonJS build |
| TypeScript | exports.types |
Declaration files |
This guarantees both frontend and backend receive the correct implementation without runtime checks or environment variables.
Understanding main, module, and types
-
main: Used by Node.js in CommonJS mode; loaded when calling
require(). - module: Used by bundlers to indicate an ESM entry point and enable tree-shaking; ignored by Node.js runtime.
- types: Used by TypeScript at compile time; provides type declarations for both frontend and backend; independent of runtime.
Why This Approach Is Stable
Module selection happens at module resolution time, not runtime.
Benefits:
- No conditional logic in application code
- No environment-dependent hacks
- Fully deterministic builds across local and CI environments
Final Thoughts
These three days of trial and error taught me a crucial lesson:
The challenge of shared packages in a monorepo is not code reuse, but defining clear module boundaries.
A robust shared package should provide:
- A single source of truth
- Multiple explicit build outputs
- Consumption strictly controlled via
exports
Trying to fit all environments into a single artifact leads to conflicts.
The dual-build strategy is one of the most reliable and maintainable patterns for shared modules in large-scale monorepos.
If you want to see this in practice, feel free to check out my real-world monorepo project for stray animal rescue. pawhaven-sahred
Top comments (0)