Monorepos
Managing multiple packages in a single repository with pnpm and Turborepo
One repo, many packages. Share code, run tools across them, coordinate releases.
What you'll learn
Understand monorepo vs multirepo tradeoffs
Set up pnpm workspaces for package management
Configure Turborepo for build orchestration
Share TypeScript configs and packages across projects
Manage dependencies across workspaces
Decide when a monorepo makes sense
Monorepo vs Multirepo
| Aspect | Monorepo | Multirepo |
|---|---|---|
| Code sharing | Direct workspace import | npm published package |
| Refactoring | One commit across packages | Multiple PRs across repos |
| CI complexity | Smart caching needed | Simple per-repo |
| Ownership | Shared visibility | Clear boundaries |
| Build time | Can be parallelized with caching | Independent builds |
pnpm Workspaces Setup
# pnpm-workspace.yaml
packages:
- "packages/*"
// package.json (root)
{
"name": "my-project",
"private": true,
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint"
},
"devDependencies": {
"turbo": "^2.0.0"
}
}
Adding Packages
# Create packages
mkdir -p packages/shared packages/web packages/api
# Each gets its own package.json
# packages/shared/package.json
{
"name": "@my-project/shared",
"version": "0.0.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"devDependencies": {
"typescript": "^5.5.0"
}
}
# Install workspace dependency
pnpm add @my-project/shared --filter @my-project/web
# Links to workspace copy, not npm registry
Shared Config Packages
// packages/tsconfig/package.json
{
"name": "@my-project/tsconfig",
"version": "0.0.0",
"files": ["base.json", "nextjs.json", "node.json"]
}
// packages/tsconfig/base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
// packages/web/tsconfig.json
{
"extends": "@my-project/tsconfig/nextjs.json",
"include": ["src"]
}
Turborepo Build Orchestration
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"], // deps build first
"outputs": [".next/**", "dist/**"],
"cache": true
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {},
"test": {
"dependsOn": ["build"]
}
}
}
Without Turborepo: web(45s) + api(30s) + shared(10s) = 85s sequential
With Turborepo: shared(10s) → web(45s) + api(30s) in parallel = 55s
With caching: Only changed packages rebuild
Turborepo Caching
Turborepo hashes inputs (source, deps, env vars). If hash matches a previous run, it restores outputs from cache instead of rebuilding. Configure outputs in turbo.json so cache knows what to save.
Dependency Management
# List all local packages
pnpm list -r --depth 0
# Add dep to specific workspace
pnpm add react --filter @my-project/web
# Update all TypeScript across workspaces
pnpm up typescript --latest
# Check for circular deps
pnpm ls --graph
# Run command in all packages
pnpm -r run build
Common Patterns
// Root devDependencies — shared tooling
{
"devDependencies": {
"typescript": "^5.5.0",
"eslint": "^9.0.0",
"prettier": "^3.0.0",
"turbo": "^2.0.0"
}
}
When Monorepo Makes Sense
Good fit:
- Multiple apps sharing UI components and types
- Backend + frontend in same ecosystem (e.g., all TypeScript)
- Coordinated releases across packages
- Small team (< 20) where cross-package visibility helps
Bad fit:
- Teams with independent release cycles
- Different tech stacks per package (Java + Node + Python)
- Giant repos causing slow CI (10k+ packages)
- Strict access control boundaries needed
Challenge
Set up a basic workspace with a shared types package consumed by two apps.
Basic Monorepo Setup
Code
ts
1// ── pnpm-workspace.yaml ──2packages:3- "packages/*"4 5// ── packages/shared/src/index.ts ──6export type User = {7id: string8name: string9email: string10}11 12// ── packages/shared/package.json ──13{14"name": "@my-project/shared",15"version": "0.0.0",16"main": "./src/index.ts",17"types": "./src/index.ts",18"devDependencies": {19"typescript": "^5.5.0"20}21}22 23// ── packages/web/src/app.ts ──24import type { User } from "@my-project/shared"25 26const currentUser: User = {27id: "user-1",28name: "Alice Johnson",29email: "alice@example.com",30}31 32function renderUser(user: User): string {33return `<div class="user-card">34<h2>${user.name}</h2>35<p>${user.email}</p>36 37</div>`38}39 40console.log(renderUser(currentUser))41 42// ── packages/api/src/handler.ts ──43import type { User } from "@my-project/shared"44 45const mockUsers: User[] = [46{ id: "1", name: "Alice", email: "alice@example.com" },47{ id: "2", name: "Bob", email: "bob@example.com" },48{ id: "3", name: "Carol", email: "carol@example.com" },49]50 51export function getUsers(): User[] {52return mockUsers53}54 55export function getUserById(id: string): User | undefined {56return mockUsers.find((u) => u.id === id)57}58 59// ── Wire deps ──60// pnpm add @my-project/shared --filter @my-project/web61// pnpm add @my-project/shared --filter @my-project/api62 63// ── Root package.json ──64{65"name": "my-project",66"private": true,67"scripts": {68"dev": "turbo dev",69"build": "turbo build"70},71"devDependencies": {72"turbo": "^2.0.0"73}74}A pnpm workspace monorepo: @my-project/shared exports a User type. Two consumer packages (web, api) import and use it — web renders user data, api returns a mock list and lookup. Root package.json uses Turborepo to orchestrate dev/build.
Key Takeaways
pnpm workspaces link local packages without npm publish
Turborepo caches build outputs — only rebuilds changed packages
^build dependency means build upstream deps first
Shared config packages (tsconfig, eslint) reduce duplication
--filter scopes commands to specific workspace packages
Monorepo works best: same ecosystem, shared code, coordinated releases
Monorepo hurts: different stacks, independent teams, strict boundaries