Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Monorepo Tooling: Turborepo and pnpm Workspaces

For .NET engineers who know: .NET Solutions (.sln), project references, MSBuild, multi-project builds, NuGet package sharing between projects You’ll learn: How pnpm workspaces and Turborepo replace the .NET solution model for JavaScript/TypeScript monorepos, with faster builds through intelligent caching Time: 15-20 minutes


The .NET Way (What You Already Know)

In .NET, a solution file (.sln) groups related projects. You have:

MySolution.sln
  MyApp.Web/            # ASP.NET Core app
  MyApp.Api/            # Another API project
  MyApp.Shared/         # Shared class library
  MyApp.Tests/          # Test project referencing the above

# MyApp.Web.csproj references MyApp.Shared.csproj:
<ProjectReference Include="..\MyApp.Shared\MyApp.Shared.csproj" />

MSBuild understands the dependency graph. When you build the solution, it builds MyApp.Shared first (because others depend on it), then builds everything else in parallel where possible.

The JavaScript ecosystem evolved a similar pattern: workspaces replace project references, and Turborepo replaces MSBuild’s orchestration — but with remote caching that MSBuild doesn’t have.


The JS/TS Monorepo Way

The Problem Monorepos Solve

Without a monorepo, you might have separate repos for your frontend, API, and shared types. Sharing types between them requires:

  • Publishing the shared types package to npm (even privately)
  • Bumping versions when types change
  • Remembering to install the updated package in dependent repos
  • Out-of-sync type definitions causing runtime bugs

A monorepo puts all packages in one repo. Shared types are referenced directly — no publishing required. A type change in packages/types is immediately visible to apps/web and apps/api without any version bumps.


pnpm Workspaces

pnpm is our package manager of choice (see article 1.3). Its workspace feature is the foundation of our monorepo setup.

Workspace configuration (pnpm-workspace.yaml at repo root):

# pnpm-workspace.yaml
packages:
  - 'apps/*'           # All directories under apps/
  - 'packages/*'       # All directories under packages/

This tells pnpm: “treat every directory under apps/ and packages/ as a workspace package.” Each directory needs its own package.json.

Root package.json:

{
  "name": "my-monorepo",
  "private": true,
  "version": "0.0.0",
  "engines": {
    "node": ">=20",
    "pnpm": ">=9"
  },
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev --parallel",
    "lint": "turbo run lint",
    "typecheck": "turbo run typecheck",
    "test": "turbo run test",
    "clean": "turbo run clean && rm -rf node_modules"
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "typescript": "^5.4.0"
  }
}

Workspace Package Structure

my-monorepo/
  pnpm-workspace.yaml
  package.json              # Root — workspace config, Turbo scripts
  turbo.json                # Turborepo configuration
  tsconfig.base.json        # Shared TypeScript config
  .gitignore
  .npmrc                    # pnpm settings

  apps/
    web/                    # Next.js frontend
      package.json
      next.config.js
      src/
    api/                    # NestJS backend
      package.json
      nest-cli.json
      src/

  packages/
    types/                  # Shared TypeScript types/interfaces
      package.json
      src/
    utils/                  # Shared utility functions
      package.json
      src/
    ui/                     # Shared React component library
      package.json
      src/
    config/                 # Shared config (ESLint, TypeScript, etc.)
      package.json
      eslint-preset.js
      tsconfig/

Defining Package Names

Each workspace package has a package.json with a name field. By convention, we namespace them:

// packages/types/package.json
{
  "name": "@myapp/types",
  "version": "0.0.1",
  "private": true,
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  },
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
    "typecheck": "tsc --noEmit",
    "lint": "eslint src/"
  },
  "devDependencies": {
    "@myapp/config": "workspace:*",
    "tsup": "^8.0.0"
  }
}
// apps/web/package.json
{
  "name": "@myapp/web",
  "version": "0.0.1",
  "private": true,
  "dependencies": {
    "@myapp/types": "workspace:*",
    "@myapp/utils": "workspace:*",
    "@myapp/ui": "workspace:*",
    "next": "^14.0.0",
    "react": "^18.0.0"
  }
}

The workspace:* protocol tells pnpm: “use the local version of this package, not the npm registry version.” This is equivalent to a <ProjectReference> in .NET.


Shared TypeScript Configuration

Rather than duplicating tsconfig.json across every package, extend a base config:

// tsconfig.base.json (root)
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "exactOptionalPropertyTypes": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}
// packages/types/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}
// apps/web/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "jsx": "preserve",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "plugins": [{ "name": "next" }]
  },
  "include": ["src/**/*", ".next/types/**/*.ts"]
}

Turborepo

pnpm workspaces handle dependency linking. Turborepo handles build orchestration and caching — the equivalent of MSBuild’s dependency-aware parallel build, but smarter.

turbo.json — the equivalent of MSBuild’s build graph:

{
  "$schema": "https://turbo.build/schema.json",
  "ui": "tui",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**/*.ts", "src/**/*.tsx", "package.json", "tsconfig.json"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "dev": {
      "dependsOn": ["^build"],
      "persistent": true,
      "cache": false
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "inputs": ["src/**/*.ts", "src/**/*.tsx", "tsconfig.json"]
    },
    "lint": {
      "inputs": ["src/**/*.ts", "src/**/*.tsx", ".eslintrc*"]
    },
    "test": {
      "dependsOn": ["^build"],
      "inputs": ["src/**/*.ts", "src/**/*.tsx", "**/*.test.ts"],
      "outputs": ["coverage/**"]
    },
    "clean": {
      "cache": false
    }
  }
}

Key concepts:

"dependsOn": ["^build"] — the ^ prefix means “build all dependencies first.” This is like <ProjectReference> forcing the referenced project to build before the referencing one. Without this, apps/web might try to build before packages/types has generated its dist/.

"inputs" — files that, when changed, invalidate the cache. If none of the input files changed, Turbo replays the cached result instantly.

"outputs" — files that are cached and restored. If the cache hits, these are restored without re-running the build.

"persistent": true — marks long-running tasks (like dev servers) that never complete. Turbo won’t wait for them to finish.

"cache": false — never cache this task (used for deploy tasks, clean, etc.)


How the Cache Works

This is where Turborepo beats MSBuild significantly. Turbo computes a hash of:

  • All input files (inputs config)
  • The task’s environment variables
  • The turbo.json configuration

If the hash matches a previous run, the task is skipped and its output is restored from cache — instantly. This works locally (.turbo/ directory) and remotely (Vercel Remote Cache or self-hosted).

# First run: all tasks execute
turbo run build
# Tasks: @myapp/types:build, @myapp/utils:build, @myapp/web:build, @myapp/api:build
# Time: 45s

# Second run with no changes: everything from cache
turbo run build
# Tasks: @myapp/types:build [CACHED], @myapp/utils:build [CACHED], ...
# Time: 0.4s

# Change only packages/types/src/user.ts
# Turbo detects which packages are affected by the change:
turbo run build
# Tasks: @myapp/types:build (changed), @myapp/web:build (depends on types), @myapp/api:build
# @myapp/utils:build [CACHED] (doesn't depend on types)
# Time: 12s

This is the “build only what changed” behavior .NET engineers know from incremental builds — but applied across packages in the repo and sharable across your entire team.


Remote Caching

Remote cache means a CI run’s results are available to other CI runs and to local machines. Once one CI machine builds @myapp/utils, no other CI machine (or developer’s laptop) needs to rebuild it unless the inputs change.

# Link to Vercel Remote Cache (free for Turborepo users)
npx turbo login
npx turbo link

# Or self-hosted with Turborepo API Server or Ducktape
# Set TURBO_API, TURBO_TOKEN, TURBO_TEAM environment variables

In GitHub Actions:

- name: Build
  run: turbo run build
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Running Scripts Across Packages

# Run "build" in all packages (respecting dependency order)
pnpm run build                        # delegates to turbo via root package.json

# Run a script in a specific package
pnpm --filter @myapp/types build
pnpm --filter @myapp/web dev

# Run in all packages matching a pattern
pnpm --filter "@myapp/*" build

# Run in a package and all its dependencies
pnpm --filter @myapp/web... build

# Run in all packages that depend on a changed package
pnpm --filter "...[origin/main]" build

# Add a dependency to a specific package
pnpm --filter @myapp/web add react-query

# Add a shared devDependency to the root
pnpm add -Dw typescript

# Add a workspace package as a dependency
pnpm --filter @myapp/web add @myapp/types
# (pnpm automatically uses workspace:* protocol)

Complete Monorepo Template

Full directory structure with file contents:

# Bootstrap command
mkdir my-monorepo && cd my-monorepo
git init

# Root package.json
cat > package.json << 'EOF'
{
  "name": "my-monorepo",
  "private": true,
  "version": "0.0.0",
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev --parallel",
    "lint": "turbo run lint",
    "typecheck": "turbo run typecheck",
    "test": "turbo run test",
    "clean": "turbo run clean && rm -rf node_modules"
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "typescript": "^5.4.0"
  },
  "engines": {
    "node": ">=20",
    "pnpm": ">=9"
  },
  "packageManager": "pnpm@9.0.0"
}
EOF

# pnpm workspace config
cat > pnpm-workspace.yaml << 'EOF'
packages:
  - 'apps/*'
  - 'packages/*'
EOF

# Turborepo config
cat > turbo.json << 'EOF'
{
  "$schema": "https://turbo.build/schema.json",
  "ui": "tui",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**/*.ts", "src/**/*.tsx", "package.json", "tsconfig.json"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "dependsOn": ["^build"],
      "persistent": true,
      "cache": false
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "inputs": ["src/**/*.ts", "src/**/*.tsx", "tsconfig.json"]
    },
    "lint": {
      "inputs": ["src/**/*.ts", "src/**/*.tsx", ".eslintrc*"]
    },
    "test": {
      "inputs": ["src/**", "**/*.test.ts"],
      "outputs": ["coverage/**"]
    },
    "clean": {
      "cache": false
    }
  }
}
EOF

# Shared types package
mkdir -p packages/types/src
cat > packages/types/package.json << 'EOF'
{
  "name": "@myapp/types",
  "version": "0.0.1",
  "private": true,
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  },
  "scripts": {
    "build": "tsup src/index.ts --format cjs --dts --clean",
    "dev": "tsup src/index.ts --format cjs --dts --watch",
    "typecheck": "tsc --noEmit",
    "lint": "echo 'lint ok'",
    "clean": "rm -rf dist"
  },
  "devDependencies": {
    "tsup": "^8.0.0",
    "typescript": "^5.4.0"
  }
}
EOF

cat > packages/types/src/index.ts << 'EOF'
export interface User {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
}

export interface ApiResponse<T> {
  data: T;
  message?: string;
}

export interface PaginatedResponse<T> extends ApiResponse<T[]> {
  total: number;
  page: number;
  pageSize: number;
}
EOF

cat > packages/types/tsconfig.json << 'EOF'
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}
EOF

# Shared utils package
mkdir -p packages/utils/src
cat > packages/utils/package.json << 'EOF'
{
  "name": "@myapp/utils",
  "version": "0.0.1",
  "private": true,
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  },
  "scripts": {
    "build": "tsup src/index.ts --format cjs --dts --clean",
    "dev": "tsup src/index.ts --format cjs --dts --watch",
    "typecheck": "tsc --noEmit",
    "lint": "echo 'lint ok'",
    "clean": "rm -rf dist"
  },
  "dependencies": {
    "@myapp/types": "workspace:*"
  },
  "devDependencies": {
    "tsup": "^8.0.0",
    "typescript": "^5.4.0"
  }
}
EOF

cat > packages/utils/src/index.ts << 'EOF'
import type { PaginatedResponse } from '@myapp/types';

export function paginate<T>(
  items: T[],
  page: number,
  pageSize: number
): PaginatedResponse<T> {
  const start = (page - 1) * pageSize;
  return {
    data: items.slice(start, start + pageSize),
    total: items.length,
    page,
    pageSize,
  };
}

export function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export function invariant(
  condition: unknown,
  message: string
): asserts condition {
  if (!condition) throw new Error(message);
}
EOF

# Base tsconfig
cat > tsconfig.base.json << 'EOF'
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "CommonJS",
    "moduleResolution": "Node",
    "strict": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}
EOF

# .gitignore
cat > .gitignore << 'EOF'
node_modules/
dist/
.next/
.turbo/
coverage/
*.tsbuildinfo
.env
.env.*
!.env.example
EOF

# Install everything
pnpm install

# Build all packages
pnpm run build

Shared ESLint Configuration

Put shared lint config in a package:

// packages/config/eslint-preset.js
module.exports = {
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:@typescript-eslint/recommended-requiring-type-checking',
  ],
  plugins: ['@typescript-eslint'],
  parser: '@typescript-eslint/parser',
  rules: {
    '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
    '@typescript-eslint/no-explicit-any': 'error',
    'no-console': ['warn', { allow: ['warn', 'error'] }],
  },
};
// packages/config/package.json
{
  "name": "@myapp/config",
  "version": "0.0.1",
  "private": true,
  "main": "eslint-preset.js",
  "dependencies": {
    "@typescript-eslint/eslint-plugin": "^7.0.0",
    "@typescript-eslint/parser": "^7.0.0",
    "eslint": "^8.0.0"
  }
}
// apps/api/.eslintrc.json
{
  "extends": ["@myapp/config"],
  "parserOptions": {
    "project": "./tsconfig.json"
  }
}

Filtering Builds in CI

Turborepo can detect which packages changed relative to a base branch and only run tasks for affected packages:

# .github/workflows/ci.yml
- name: Build affected packages
  run: pnpm turbo run build --filter="...[origin/main]"
  # ^ Only builds packages that changed from main, plus their dependents

This is equivalent to Azure Pipelines path-based triggers, but smarter: if you change packages/types, Turbo knows to rebuild apps/web and apps/api (because they depend on types) but not packages/utils (if it doesn’t depend on types).


Key Differences

.NET ConceptJS Monorepo EquivalentNotes
.sln filepnpm-workspace.yamlDefines which projects are in the workspace
.csprojpackage.jsonEach package’s manifest
<ProjectReference>"@myapp/types": "workspace:*"Local package dependency
MSBuild dependency graphturbo.json dependsOnDefines build order
MSBuild incremental buildTurborepo local cacheHash-based, per-task
No MSBuild equivalentTurborepo remote cacheShared cache across machines/CI
NuGet packagePublished npm packageFor external sharing; internal use workspace:*
Shared class librarypackages/ workspaceTypes, utils, UI components
dotnet buildpnpm run build (→ turbo run build)Runs all tasks in dependency order
dotnet testpnpm run test (→ turbo run test)Same
Solution-wide restorepnpm installInstalls all workspace deps at once

Gotchas for .NET Engineers

Gotcha 1: workspace:* does not auto-build the dependency. Adding "@myapp/types": "workspace:*" to your app’s dependencies symlinks the package, but it does NOT build it. If packages/types hasn’t been built (no dist/ directory), importing from it will fail. The "dependsOn": ["^build"] in turbo.json handles this for Turbo commands — but if you run pnpm --filter @myapp/web dev directly without Turbo, you may get import errors until you manually build the dependencies first.

Gotcha 2: TypeScript paths and exports must match. When apps/web imports from '@myapp/types', TypeScript resolves this through the package’s types field in package.json (pointing to dist/index.d.ts). If the dist/ isn’t there (not built yet) or if the exports field doesn’t include types, TypeScript will report Cannot find module '@myapp/types'. This is not a missing package — it’s a missing build artifact.

Gotcha 3: Changing turbo.json invalidates the entire cache. Any change to turbo.json causes Turborepo to consider all existing cache entries invalid, since the task definition itself changed. Similarly, changing tsconfig.base.json invalidates the cache for all TypeScript builds if it’s listed in the inputs. This is correct behavior but can surprise you when a small config tweak triggers a full rebuild.

Gotcha 4: pnpm install must be run from the repo root. Running pnpm install inside a package directory (e.g., cd packages/types && pnpm install) creates a separate node_modules inside that package and breaks the workspace symlinks. Always run pnpm install from the monorepo root. If you accidentally do this, delete the nested node_modules and run pnpm install from root again.

Gotcha 5: Circular dependencies between packages will cause hard-to-debug errors. If packages/utils depends on packages/types, and you later add a dependency from packages/types to packages/utils, Turbo will detect the circular dependency and refuse to build. This is the equivalent of circular project references in .NET solutions, which the compiler rejects. Design your package dependency graph as a DAG (directed acyclic graph): apps depend on packages, and packages can depend on other packages but not apps.

Gotcha 6: turbo.json outputs must include all generated files, or caching breaks. If your build generates files that aren’t listed in outputs, those files won’t be restored from cache on a cache hit. The task appears to succeed (it returns the cached result) but your dist/ is missing files. Always enumerate all generated artifacts in outputs. Use ! exclusions for large build caches you don’t want to store: "outputs": [".next/**", "!.next/cache/**"].


Hands-On Exercise

Set up a minimal monorepo with a shared types package consumed by two apps:

mkdir turbo-practice && cd turbo-practice

# Install pnpm if needed
npm install -g pnpm

# Initialize
cat > pnpm-workspace.yaml << 'EOF'
packages:
  - 'apps/*'
  - 'packages/*'
EOF

cat > package.json << 'EOF'
{
  "name": "turbo-practice",
  "private": true,
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev --parallel",
    "typecheck": "turbo run typecheck"
  },
  "devDependencies": {
    "turbo": "latest",
    "typescript": "^5.4.0"
  }
}
EOF

cat > turbo.json << 'EOF'
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "typecheck": {
      "dependsOn": ["^build"]
    }
  }
}
EOF

cat > tsconfig.base.json << 'EOF'
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "moduleResolution": "Node",
    "strict": true,
    "declaration": true,
    "skipLibCheck": true
  }
}
EOF

# Create shared types package
mkdir -p packages/types/src
cat > packages/types/package.json << 'EOF'
{
  "name": "@practice/types",
  "version": "0.0.1",
  "private": true,
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "typescript": "^5.4.0"
  }
}
EOF

cat > packages/types/tsconfig.json << 'EOF'
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": { "outDir": "dist", "rootDir": "src" },
  "include": ["src"]
}
EOF

cat > packages/types/src/index.ts << 'EOF'
export interface Greeting {
  message: string;
  timestamp: Date;
}
EOF

# Create app-a
mkdir -p apps/app-a/src
cat > apps/app-a/package.json << 'EOF'
{
  "name": "@practice/app-a",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "build": "tsc",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@practice/types": "workspace:*"
  },
  "devDependencies": {
    "typescript": "^5.4.0"
  }
}
EOF

cat > apps/app-a/tsconfig.json << 'EOF'
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": { "outDir": "dist", "rootDir": "src" },
  "include": ["src"]
}
EOF

cat > apps/app-a/src/index.ts << 'EOF'
import type { Greeting } from '@practice/types';

const greeting: Greeting = {
  message: 'Hello from app-a',
  timestamp: new Date(),
};

console.log(greeting.message);
EOF

# Install everything
pnpm install

# Build all (Turbo builds packages/types first, then apps/app-a)
pnpm run build

# Notice the output — types built before app-a
# Now run again — everything is cached
pnpm run build

# Modify types/src/index.ts and build again
# Only types and app-a rebuild; any uncached packages would rebuild too
echo 'export type GreetingType = "formal" | "casual";' >> packages/types/src/index.ts
pnpm run build
# app-a rebuilds because its dependency changed

Quick Reference

# Install all workspace dependencies
pnpm install

# Add dependency to specific package
pnpm --filter @myapp/web add react-query
pnpm --filter @myapp/web add @myapp/types   # workspace:* auto-set

# Add devDependency to root (shared tooling)
pnpm add -Dw typescript turbo

# Run script in all packages
pnpm run build              # Via turbo (respects dependency order + cache)

# Run in specific package
pnpm --filter @myapp/web dev

# Run in package + all its dependencies
pnpm --filter "@myapp/web..." build

# Run for all changed packages (CI)
turbo run build --filter="...[origin/main]"

# Inspect workspace
pnpm list --depth 0         # All workspace packages
pnpm why some-package       # Why is this installed?

# Clear turbo cache
turbo run build --force     # Bypass cache for this run
rm -rf .turbo               # Delete all local cache
// turbo.json task patterns
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],     // ^ = build dependencies first
      "inputs": ["src/**/*.ts"],   // Cache key inputs
      "outputs": ["dist/**"]       // Files to cache and restore
    },
    "dev": {
      "persistent": true,          // Long-running, never "done"
      "cache": false               // Never cache
    },
    "test": {
      "dependsOn": ["build"],      // No ^ = depend on same package's build
      "outputs": ["coverage/**"]
    }
  }
}
# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
  - 'tools/*'

Further Reading