DEV Community

Aoda Zhang
Aoda Zhang

Posted on

How to sharing TypeScript codes Across Frontend and Backend in a Monorepo

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 moduleResolution strategies
  • Various tsconfig combinations

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
Enter fullscreen mode Exit fullscreen mode
  • 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"]
}

Enter fullscreen mode Exit fullscreen mode
// tsconfig.esm.json
{
  "extends": "@pawhaven/tsconfig/base",
  "compilerOptions": {
    "outDir": "dist/esm",
    "module": "ESNext",
    "moduleResolution": "bundler"
  },
  "exclude": ["node_modules", "dist"]
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
![ ](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yi9mvpqhkigfvr8f26ri.png)
Enter fullscreen mode Exit fullscreen mode

The package.json defines which build is loaded for different consumers:

  • main points to the CommonJS build for Node.js (require)
  • module points to the ESM build for bundlers (import)
  • types points 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)