TypeScript Monorepo in Practice: 6 Key Strategies from Turborepo Configuration to CI/CD Optimization

前端工程

Monorepo Pain: Only Those Who've Used It Know

5 packages, 3 apps, 2 CI configs — every time you change a utility function, you manually publish the npm package, update dependency versions, and redeploy 3 apps. Lerna is unmaintained, Nx has a steep learning curve, Rush is complex to configure. In 2026, Turborepo + pnpm workspace finally makes TypeScript Monorepo go from "it works" to "it's great" — incremental builds, remote caching, pipeline orchestration, one command does it all.

This article covers 6 key strategies, guiding you through project initialization → task orchestration → remote caching → CI/CD optimization → package publishing → monitoring.


TypeScript Monorepo Core Concepts

Concept Description
Monorepo Engineering pattern managing multiple packages/apps in a single repository
pnpm workspace pnpm's native Monorepo support, declaring package directories via pnpm-workspace.yaml
Turborepo Vercel's high-performance Monorepo build system, core features are task pipelines and caching
Task Pipeline Defines dependency relationships and execution order between tasks, e.g., build depends on ^build
Remote Cache Uploads build cache remotely, shared across team members and CI
Incremental Build Only rebuilds changed packages and their dependents, skipping unchanged ones
Changeset Tool for managing package versions and changelogs, supporting coordinated multi-package releases
Workspace Protocol pnpm's workspace:* protocol, inter-package references always point to local source

Problem Analysis: 5 Major Monorepo Challenges

  1. Dependency hell: Package A depends on B@1.0, Package C depends on B@2.0, version conflicts hard to resolve
  2. Build performance: Full builds range from minutes to hours, CI costs skyrocket
  3. CI/CD configuration: Each package has separate pipeline configs, high maintenance cost and inconsistency
  4. Package publishing flow: Multi-package version management, changelog generation, release order coordination
  5. Developer experience: TypeScript project references are complex, IDE navigation and type checking are slow

Step-by-Step: 6 Key Strategies

Strategy 1: Project Initialization & pnpm Workspace

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
{
  "name": "my-monorepo",
  "private": true,
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint",
    "test": "turbo run test",
    "clean": "turbo run clean"
  },
  "devDependencies": {
    "turbo": "^2.3.0",
    "typescript": "^5.7.0"
  },
  "packageManager": "pnpm@9.15.0"
}
// apps/web/package.json
{
  "name": "@my-org/web",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "lint": "eslint . --ext .ts,.tsx",
    "test": "vitest run"
  },
  "dependencies": {
    "@my-org/ui": "workspace:*",
    "@my-org/utils": "workspace:*",
    "next": "^15.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@types/react": "^19.0.0",
    "typescript": "^5.7.0"
  }
}
// packages/ui/package.json
{
  "name": "@my-org/ui",
  "version": "2.1.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.js"
    }
  },
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
    "lint": "eslint src/",
    "test": "vitest run",
    "clean": "rm -rf dist"
  },
  "peerDependencies": {
    "react": "^19.0.0"
  },
  "devDependencies": {
    "tsup": "^8.0.0",
    "typescript": "^5.7.0",
    "vitest": "^3.0.0"
  }
}

Strategy 2: Turborepo Task Pipeline Configuration

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "globalEnv": ["NODE_ENV"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
      "outputMode": "new-only"
    },
    "dev": {
      "cache": false,
      "persistent": true,
      "dependsOn": ["^build"]
    },
    "lint": {
      "dependsOn": ["^build"],
      "outputs": []
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"],
      "inputs": ["src/**/*.ts", "test/**/*.ts"]
    },
    "clean": {
      "cache": false
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "outputs": []
    }
  }
}
// packages/utils/src/index.ts
export function formatDate(date: Date, locale: string = 'en-US'): string {
  return new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
  }).format(date);
}

export function debounce<T extends (...args: any[]) => any>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void {
  let timer: ReturnType<typeof setTimeout> | null = null;
  return (...args: Parameters<T>) => {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

export function deepClone<T>(obj: T): T {
  return structuredClone(obj);
}

export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };

export function tryCatch<T, E = Error>(fn: () => T): Result<T, E> {
  try {
    return { ok: true, value: fn() };
  } catch (error) {
    return { ok: false, error: error as E };
  }
}

Strategy 3: Remote Cache Configuration

// turbo.json remote cache setup
// Option 1: Turborepo Remote Cache (Vercel hosted)
// $ npx turbo login
// $ npx turbo link

// Option 2: Self-hosted Remote Cache
# docker-compose.yml - Self-hosted Turborepo Remote Cache
version: '3.8'
services:
  turbo-cache:
    image: ghcr.io/ducktors/turborepo-remote-cache:latest
    ports:
      - "3001:3000"
    environment:
      - STORAGE_TYPE=s3
      - S3_BUCKET=turbo-cache
      - S3_REGION=us-east-1
      - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
      - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
    restart: unless-stopped
# .turbo/config.json or environment variables
# TURBO_API=http://localhost:3001
# TURBO_TOKEN=your-team-token
# TURBO_TEAM=my-team

# Use remote cache in CI
export TURBO_API="https://cache.my-company.com"
export TURBO_TOKEN="${{ secrets.TURBO_TOKEN }}"
export TURBO_TEAM="my-team"
pnpm turbo run build --remote-cache-only

Strategy 4: CI/CD Pipeline Optimization

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  TURBO_API: ${{ vars.TURBO_API }}
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}

jobs:
  lint-typecheck:
    name: Lint & TypeCheck
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - run: pnpm turbo run lint typecheck --concurrency=4

  test:
    name: Test
    runs-on: ubuntu-latest
    needs: lint-typecheck
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - run: pnpm turbo run test --concurrency=2

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-reports
          path: '**/coverage/'
          retention-days: 7

  build:
    name: Build
    runs-on: ubuntu-latest
    needs: [lint-typecheck, test]
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - run: pnpm turbo run build --concurrency=3

      - uses: actions/upload-artifact@v4
        with:
          name: build-outputs
          path: |
            apps/*/dist/
            apps/*/.next/
            packages/*/dist/
          retention-days: 3

Strategy 5: Changeset Multi-Package Publishing

// packages/ui/package.json - add changeset config
{
  "devDependencies": {
    "@changesets/cli": "^2.28.0"
  }
}
// .changeset/config.json
{
  "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "fixed": [["@my-org/ui", "@my-org/utils"]],
  "linked": [["@my-org/ui", "@my-org/utils"]],
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": ["@my-org/web", "@my-org/docs"]
}
# .github/workflows/release.yml
name: Release

on:
  push:
    branches: [main]

concurrency: ${{ github.workflow }}-${{ github.ref }}

env:
  TURBO_API: ${{ vars.TURBO_API }}
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}
  NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

jobs:
  release:
    name: Release
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'
          registry-url: 'https://registry.npmjs.org'

      - run: pnpm install --frozen-lockfile

      - run: pnpm turbo run build --filter=./packages/*

      - name: Create Release Pull Request or Publish
        id: changesets
        uses: changesets/action@v1
        with:
          publish: pnpm changeset publish
          version: pnpm changeset version
          title: "chore: version packages"
          commit: "chore: version packages"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Strategy 6: TypeScript Project References & Incremental Compilation

// tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "composite": true,
    "incremental": true
  }
}
// packages/utils/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}
// apps/web/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "jsx": "preserve",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "noEmit": true,
    "incremental": true,
    "paths": {
      "@my-org/ui": ["../../packages/ui/src"],
      "@my-org/utils": ["../../packages/utils/src"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"],
  "references": [
    { "path": "../../packages/ui" },
    { "path": "../../packages/utils" }
  ]
}

Pitfall Guide

Pitfall 1: workspace:* Not Replaced on Publish

// ❌ Wrong: workspace:* published as-is to npm
{
  "dependencies": {
    "@my-org/utils": "workspace:*"
  }
}

// ✅ Correct: pnpm publish auto-replaces, but verify pnpm version >= 8
// pnpm automatically replaces workspace:* with actual version numbers
// Run in CI: pnpm -r publish --access public

Pitfall 2: Missing ^build Dependency in turbo.json

// ❌ Wrong: build doesn't depend on upstream builds, references uncompiled source
{
  "tasks": {
    "build": {
      "dependsOn": ["build"]
    }
  }
}

// ✅ Correct: ^build means wait for dependency packages to build first
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"]
    }
  }
}

Pitfall 3: CI Not Configured for Cache Hits

# ❌ Wrong: full build every CI run
- run: pnpm turbo run build

# ✅ Correct: configure remote cache + frozen lockfile
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build
  env:
    TURBO_API: ${{ vars.TURBO_API }}
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Pitfall 4: tsup Not Generating dts Files

// ❌ Wrong: only outputs JS, no type declarations
{
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm"
  }
}

// ✅ Correct: add --dts to generate .d.ts files
{
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts"
  }
}

Pitfall 5: Changeset Not Ignoring Private Packages

// ❌ Wrong: private app packages also participate in changeset publishing
{
  "ignore": []
}

// ✅ Correct: private app packages don't need npm publishing, should be ignored
{
  "ignore": ["@my-org/web", "@my-org/docs", "@my-org/admin"]
}

Error Troubleshooting

# Error Message Cause Solution
1 ERR_PNPM_PEER_DEP_ISSUES Peer dependency version conflict Configure pnpm.peerDependencyRules.allowedVersions in package.json
2 turbo: no tasks found turbo.json not configured or package missing script Verify turbo.json exists and package has matching script
3 ERR_PNPM_WORKSPACE_PKG_NOT_FOUND workspace:* references non-existent package Check pnpm-workspace.yaml and package name fields
4 Type error: Cannot find module '@my-org/utils' TypeScript path mapping not configured Configure paths and references in tsconfig.json
5 turbo cache miss Cache key changed or remote cache not configured Check inputs/outputs config, configure TURBO_API
6 npm ERR! 403 Forbidden npm publish permission denied Verify NPM_TOKEN is valid and package scope is configured
7 Changeset: No changeset files found No changeset files created Run pnpm changeset to create change descriptions
8 ERR_PNPM_LOCKFILE_MISSING pnpm-lock.yaml missing Run pnpm install to generate lockfile
9 turbo: task "build" failed in "@my-org/ui" Dependency package build failed Build failed package first: pnpm turbo run build --filter=@my-org/ui
10 ESLint: Cannot read config file ESLint config path wrong in monorepo Use @my-org/eslint-config for unified configuration

Advanced Optimization

1. Turborepo Filter Precision Execution

# Only build changed packages and their dependents
pnpm turbo run build --filter=...[HEAD^1]

# Only build specific package
pnpm turbo run build --filter=@my-org/ui

# Build all packages except specified ones
pnpm turbo run build --filter=!@my-org/web

# Build all packages that depend on @my-org/utils
pnpm turbo run build --filter=...@my-org/utils

# Combined filter: build changed packages under apps
pnpm turbo run build --filter=./apps/*[HEAD^1]

2. Shared ESLint and TypeScript Configuration

// packages/eslint-config/package.json
{
  "name": "@my-org/eslint-config",
  "version": "1.0.0",
  "main": "index.js",
  "dependencies": {
    "@typescript-eslint/eslint-plugin": "^8.0.0",
    "@typescript-eslint/parser": "^8.0.0",
    "eslint-config-next": "^15.0.0",
    "eslint-plugin-react": "^7.37.0",
    "eslint-plugin-react-hooks": "^5.0.0"
  }
}
// packages/eslint-config/index.js
module.exports = {
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
  ],
  rules: {
    '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
    '@typescript-eslint/explicit-function-return-type': 'off',
    '@typescript-eslint/no-explicit-any': 'warn',
    'no-console': ['warn', { allow: ['warn', 'error'] }],
  },
  overrides: [
    {
      files: ['**/*.tsx'],
      extends: ['plugin:react/recommended', 'plugin:react-hooks/recommended'],
      settings: {
        react: { version: 'detect' },
      },
    },
  ],
};

3. Monorepo Health Monitoring

// scripts/monorepo-health.ts
import { execSync } from 'child_process';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';

interface PackageHealth {
  name: string;
  hasReadme: boolean;
  hasTests: boolean;
  hasBuildScript: boolean;
  outdatedDeps: number;
  typeCheckPass: boolean;
}

function checkMonorepoHealth(): PackageHealth[] {
  const workspaces = execSync('pnpm ls -r --depth 0 --json', { encoding: 'utf-8' });
  const packages = JSON.parse(workspaces);

  return packages.map((pkg: any) => {
    const pkgDir = pkg.path;
    const pkgJson = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf-8'));

    return {
      name: pkg.name,
      hasReadme: existsSync(join(pkgDir, 'README.md')),
      hasTests: existsSync(join(pkgDir, 'test')) || existsSync(join(pkgDir, '__tests__')),
      hasBuildScript: 'build' in (pkgJson.scripts || {}),
      outdatedDeps: 0,
      typeCheckPass: true,
    };
  });
}

const health = checkMonorepoHealth();
console.table(health);

Comparison Analysis

Dimension Turborepo + pnpm Nx Lerna Rush pnpm only
Learning Curve ⭐ Low ⭐⭐⭐ High ⭐⭐ Medium ⭐⭐⭐ High ⭐ Low
Incremental Build ✅ Built-in ✅ Built-in ❌ Needs config ✅ Built-in
Remote Cache ✅ Vercel/self-hosted ✅ Nx Cloud ✅ BuildCache
Task Orchestration ✅ Pipeline ✅ Very strong ⚠️ Basic
Package Publishing ⚠️ Needs Changeset ✅ Built-in ✅ Built-in ✅ Built-in
Dependency Management ✅ pnpm ✅ pnpm/npm/yarn ⚠️ npm/yarn ✅ pnpm ✅ pnpm
Maintenance Status ✅ Active ✅ Active ❌ Unmaintained ✅ Active ✅ Active
Ecosystem Integration ⭐ Next.js first ⭐ Angular first ⭐ General ⭐ General ⭐ General

Summary: Turborepo isn't "yet another Monorepo tool" — it's "a build caching revolution". Its core value isn't task orchestration (pnpm -r can do that too), but intelligent caching — local cache saves developers from repeated builds, remote cache saves CI from repeated builds, --filter ensures only what should be built gets built. 2026 Monorepo selection: small teams use Turborepo + pnpm (zero config, works out of the box), large teams use Nx (stronger code generation and dependency graph analysis), don't use Lerna (unmaintained).


Try these browser-local tools — no sign-up required →

#TypeScript#Monorepo#Turborepo#pnpm#CI/CD#2026#前端工程化