Production Monorepos with Turborepo, pnpm Workspaces & Shared TypeScript Configs

Managing multiple microservices and frontend clients across fragmented repositories causes version drift and slow builds. Discover how to architect a high-velocity production monorepo using pnpm workspaces and Turborepo.

The Monorepo Dilemma: Multi-Repo Chaos vs. Scalable Workspaces

As engineering organizations expand, code distribution frequently fragments across separate repositories: one repo for the customer portal (React), another for internal administrative tooling, a third for Node.js API microservices, and a fourth for shared TypeScript domain types published to a private npm registry. While this multi-repo approach provides superficial team isolation, it inevitably introduces severe architectural friction:

  • Version Mismatch Drift: Updating an API schema requires bumping a shared types package, creating a pull request, publishing a new npm package version, and subsequently bumping dependencies across three separate repositories.
  • Broken Integration Testing: Breaking changes cannot be verified atomically across frontend and backend in a single git commit.
  • Duplicate CI Pipelines: Every repository independently installs dependencies, runs linters, and executes duplicate builds, wasting thousands of compute minutes each month.

A production monorepo solves these challenges by collocating applications and packages into a single git repository managed with pnpm workspaces and accelerated with Turborepo computation caching.

1. Structuring pnpm Workspaces for Strict Isolation

Unlike npm or yarn, pnpm utilizes content-addressable hard links to eliminate duplicate node_modules across projects while strictly enforcing dependency isolation (preventing "phantom dependencies" where an app accidentally imports a package it never explicitly declared in package.json).

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

A typical enterprise monorepo directory layout:

├── apps/
│   ├── web/               # Next.js / React Customer Application
│   ├── admin/             # Internal React Administrative Console
│   └── api/               # Fastify / Node.js Microservice
├── packages/
│   ├── tsconfig/          # Shared TypeScript base configs
│   ├── eslint-config/     # Shared linting rules
│   ├── types/             # Shared domain types & Zod schemas
│   └── ui/                # Shared Tailwind design system components
├── package.json
├── pnpm-workspace.yaml
└── turbo.json

2. The Internal Package Architecture: Zero Build Steps for Types

A major bottleneck in naive monorepos is requiring packages like @devmanue/types or @devmanue/ui to be compiled via Rollup/tsc before they can be consumed by applications during local development. In modern TypeScript 5+, we configure internal packages with subpath exports pointing directly to raw TypeScript source files:

// packages/types/package.json
{
  "name": "@devmanue/types",
  "version": "0.0.1",
  "private": true,
  "exports": {
    ".": "./src/index.ts",
    "./api": "./src/api.ts",
    "./domain": "./src/domain.ts"
  },
  "scripts": {
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "@devmanue/tsconfig": "workspace:*"
  }
}

When apps/web or apps/api imports from @devmanue/types/domain, the build tool (Next.js, Vite, or tsx) compiles the raw TypeScript directly. You modify a domain type in packages/types and both your backend and frontend hot-reload instantaneously with zero manual build steps!

3. Shared TypeScript Configurations

Avoid duplicating compiler options across 10 different tsconfig.json files. Define composable base configs in packages/tsconfig:

// packages/tsconfig/base.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "noUncheckedIndexedAccess": true,
    "declaration": true
  }
}

Applications extend the shared configuration cleanly:

// apps/api/tsconfig.json
{
  "extends": "@devmanue/tsconfig/base.json",
  "compilerOptions": {
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}

4. Supercharging CI with Turborepo Pipeline Caching

Turborepo turns task execution into a Directed Acyclic Graph (DAG) and caches the inputs and outputs of every task. If code in apps/api changes, Turborepo builds only apps/api and skips apps/web entirely:

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    },
    "typecheck": {
      "dependsOn": ["^typecheck"]
    },
    "lint": {},
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

5. CI Acceleration Metrics

By enabling Turborepo Remote Caching in GitHub Actions, team members and CI runners share build artifacts globally:

# Execution Output
turbo run build test --cache-dir=.turbo

• Packages in scope: @devmanue/web, @devmanue/admin, @devmanue/api, @devmanue/ui, @devmanue/types
• Running build, test in 5 packages
@devmanue/types:build: cache hit, replaying logs 72ms >>> FULL TURBO
@devmanue/ui:build:    cache hit, replaying logs 94ms >>> FULL TURBO
@devmanue/admin:build: cache hit, replaying logs 112ms >>> FULL TURBO
@devmanue/api:build:   [completed in 1.4s]

 Tasks:    4 successful, 4 total
Cached:    3 cached, 4 total
  Time:    1.52s >>> SAVED 4m 12s
Architectural Continuity & Deep Dives

For related production architectures and system implementations, explore these companion guides:

Key Architectural Takeaways

  • Strict Isolation: Use pnpm workspaces with workspace:* dependencies to eliminate phantom dependency bugs.
  • Skip Build Steps for Types: Export raw TypeScript source files in internal packages to achieve instant cross-package hot-reloading.
  • Cache Everything: Leverage Turborepo's DAG pipeline and remote caching to drop CI build times from 10+ minutes to under 60 seconds.
All Insights
Chat on WhatsApp