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 (
inputsconfig) - 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 Concept | JS Monorepo Equivalent | Notes |
|---|---|---|
.sln file | pnpm-workspace.yaml | Defines which projects are in the workspace |
.csproj | package.json | Each package’s manifest |
<ProjectReference> | "@myapp/types": "workspace:*" | Local package dependency |
| MSBuild dependency graph | turbo.json dependsOn | Defines build order |
| MSBuild incremental build | Turborepo local cache | Hash-based, per-task |
| No MSBuild equivalent | Turborepo remote cache | Shared cache across machines/CI |
| NuGet package | Published npm package | For external sharing; internal use workspace:* |
| Shared class library | packages/ workspace | Types, utils, UI components |
dotnet build | pnpm run build (→ turbo run build) | Runs all tasks in dependency order |
dotnet test | pnpm run test (→ turbo run test) | Same |
| Solution-wide restore | pnpm install | Installs 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/*'