As TypeScript projects grow beyond a single application — adding shared libraries, API clients, configuration packages, and multiple deployable services — managing them as separate repositories creates coordination overhead: cross-repo version pinning, diamond dependency conflicts, and the impossibility of atomic changes across packages. A monorepo consolidates all packages into a single repository, but introduces its own challenge: how to build, test, and lint dozens of interconnected packages efficiently without rebuilding everything on every change.
A well-structured monorepo project solves this with two complementary tools. pnpm Workspaces handles package management: declaring which directories are packages, symlinking local dependencies so packages can import each other without publishing to npm, and deduplicating shared dependencies across the workspace. Turborepo handles task orchestration: understanding the dependency graph between packages, running tasks (build, test, lint) in the correct order with maximum parallelism, and caching results so unchanged packages aren’t rebuilt. Together, they transform a monorepo from “build everything every time” to “build only what changed, in the right order, as fast as possible.”
This topic covers pnpm workspace configuration, the workspace protocol for local package references, Turborepo pipeline definitions, caching strategies, and patterns for structuring shared configuration across a TypeScript monorepo.
A pnpm-workspace.yaml file at the repository root declares which directories contain packages (e.g., packages/*, apps/*). When a package lists another workspace package as a dependency using the workspace:* protocol, pnpm creates a symlink instead of fetching from the npm registry. This means import { Button } from "@myorg/ui" in the app resolves directly to the local packages/ui/src directory — changes to the UI package are immediately visible without publishing or version bumping. The workspace protocol ensures these local references are replaced with real version numbers when packages are published to npm.
Turborepo reads each package’s package.json and the workspace dependency graph to determine task execution order. If package A depends on package B, and both have a build script, Turborepo ensures B builds before A. Independent packages build in parallel. The pipeline configuration in turbo.json declares which tasks depend on which other tasks (e.g., build depends on ^build — the caret meaning “the build task of my dependencies”). This dependency-aware scheduling is the key difference from running pnpm -r build, which executes in topological order but sequentially.
Turborepo computes a hash for each task based on: the package’s source files, its dependencies’ output hashes, environment variables, and the task command itself. If the hash matches a previous run, Turborepo replays the cached output (stdout, stderr, and output files) instead of re-executing the task. This means a turbo run build in CI after a one-line change to a leaf package only rebuilds that package and its dependents — not the entire monorepo. Remote caching (via Vercel or self-hosted) shares the cache across CI runs and developer machines.
The workspace:* protocol in package.json dependencies tells pnpm “resolve this from the workspace, not npm.” Variants include workspace:^ (caret range when published), workspace:~ (tilde range), and workspace:* (exact version). During development, all variants resolve to the local symlink. When publishing, pnpm replaces the protocol with the actual version number and the specified range operator. This dual behavior — local linking in development, proper versioning in production — is what makes workspace packages publishable without manual version management.
In a monorepo, configuration duplication across packages (TypeScript config, ESLint rules, Prettier settings) is a maintenance burden. The pattern is to create internal packages like @myorg/tsconfig and @myorg/eslint-config that export shared configuration. Each application or library package extends the shared config: { "extends": "@myorg/tsconfig/base.json" }. Changes to the shared config propagate to all packages automatically, and package-specific overrides are still possible. Turborepo’s caching correctly invalidates when shared config packages change.
The monorepo is structured with a root pnpm-workspace.yaml that declares package locations, a turbo.json that defines the task pipeline, and individual packages each with their own package.json and build configuration. When a developer runs turbo run build, Turborepo analyzes the full dependency graph, determines what’s changed since the last run, and executes only the necessary tasks in parallel.
# Repository layout
my-monorepo/
├── pnpm-workspace.yaml # Declares workspace packages
├── turbo.json # Task pipeline configuration
├── package.json # Root package (devDependencies, scripts)
├── pnpm-lock.yaml # Single lockfile for entire workspace
├── apps/
│ ├── dashboard/ # Next.js web application
│ │ ├── package.json
│ │ └── src/
│ ├── api-server/ # Express API service
│ │ ├── package.json
│ │ └── src/
│ └── cli/ # Command-line tool
│ ├── package.json
│ └── src/
├── packages/
│ ├── ui/ # Shared React components
│ │ ├── package.json
│ │ └── src/
│ ├── db/ # Database client & migrations
│ │ ├── package.json
│ │ └── src/
│ ├── shared-types/ # TypeScript type definitions
│ │ ├── package.json
│ │ └── src/
│ └── config/ # Shared TS/ESLint/Prettier configs
│ ├── tsconfig/
│ └── eslint/
└── .github/
└── workflows/
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
// packages/ui/package.json
{
"name": "@myorg/ui",
"version": "0.1.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts",
"lint": "eslint src/",
"test": "vitest run"
},
"dependencies": {
"@myorg/shared-types": "workspace:*",
"react": "^18.2.0"
},
"devDependencies": {
"@myorg/tsconfig": "workspace:*",
"tsup": "^8.0.0",
"vitest": "^1.0.0"
}
}
// apps/dashboard/package.json
{
"name": "@myorg/dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"lint": "eslint src/",
"test": "vitest run"
},
"dependencies": {
"@myorg/ui": "workspace:*",
"@myorg/db": "workspace:*",
"@myorg/shared-types": "workspace:*",
"next": "^14.0.0",
"react": "^18.2.0"
}
}
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [
"**/.env.*local"
],
"pipeline": {
"build": {
// ^build means: run the build task of my workspace dependencies first.
"dependsOn": ["^build"],
// Output directories to cache.
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"test": {
// Tests depend on the package being built first.
"dependsOn": ["build"],
"outputs": ["coverage/**"]
},
"lint": {
// Linting has no inter-package dependencies — run in parallel.
"outputs": []
},
"dev": {
// Dev servers are long-running, no caching.
"cache": false,
"persistent": true
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": []
}
}
}
# Build all packages (respecting dependency order, with caching).
turbo run build
# Build only the dashboard and its dependencies.
turbo run build --filter=@myorg/dashboard...
# Run tests only for packages changed since main.
turbo run test --filter=...[main]
# Lint all packages in parallel (no dependencies between lint tasks).
turbo run lint
# Start dev servers for dashboard and its dependencies.
turbo run dev --filter=@myorg/dashboard...
A typical monorepo contains a web dashboard, API server, CLI tool, and shared packages for database access, UI components, and type definitions. Before adopting Turborepo, a change to the shared-types package required manually building shared-types, then ui (which depends on shared-types), then dashboard (which depends on both). Developers frequently forgot a step and wondered why their changes weren’t reflected. Turborepo’s dependency-aware pipeline eliminates this by computing the correct build order automatically.
Caching transforms CI performance. A Turborepo CI pipeline runs turbo run build test lint typecheck on every PR. On a typical PR that touches one package, ~90% of tasks are cache hits — the pipeline completes in under 2 minutes instead of the 12+ minutes required for a full rebuild. Remote caching means this speedup persists across different CI runs and even between CI and local development: a package built in CI doesn’t need to be rebuilt on the developer’s machine.
The --filter flag enables scoped development. A developer working on the CLI tool runs turbo run dev --filter=@myorg/cli..., which starts only the CLI and its workspace dependencies in watch mode. This avoids the resource overhead of running all dev servers simultaneously and focuses the developer’s attention on the relevant packages.
Mark application packages (apps) and internal-only libraries as "private": true in their package.json. This prevents accidental npm publishing and signals that the package is only consumed within the monorepo. Published packages (those intended for external consumers) omit the private field and use workspace:^ for their workspace dependencies so the version range is properly set when publishing.
// packages/config/tsconfig/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ES2022",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"exclude": ["node_modules", "dist"]
}
// packages/config/tsconfig/nextjs.json
{
"extends": "./base.json",
"compilerOptions": {
"jsx": "preserve",
"lib": ["dom", "dom.iterable", "ES2022"],
"module": "ESNext",
"plugins": [{ "name": "next" }]
}
}
// apps/dashboard/tsconfig.json — extends shared config
{
"extends": "@myorg/tsconfig/nextjs.json",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "next-env.d.ts"],
"exclude": ["node_modules"]
}
Turborepo can include environment variables in the task hash via the globalEnv and per-task env fields. This ensures that a build with NODE_ENV=production has a different cache key than one with NODE_ENV=development. Without this, switching from a dev build to a production build could serve cached dev output. List all environment variables that affect build output in turbo.json to prevent stale cache hits.
// turbo.json — environment-aware caching
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["NODE_ENV", "API_BASE_URL", "NEXT_PUBLIC_*"]
}
}
}
In a workspace, packages can accidentally import dependencies that they don’t declare in their own package.json — they resolve because another workspace package installed them, and pnpm hoists them to the root node_modules. This works locally but breaks when the package is published or consumed in a different context. Use pnpm’s --strict-peer-dependencies and the .pnpmfile.cjs hook to detect undeclared imports. Alternatively, set hoist: false in .npmrc for strict isolation (at the cost of disk space).
If turbo.json declares "outputs": ["dist/**"] but the build actually produces artifacts in build/ or .next/, Turborepo caches the wrong files. On a cache hit, the output directory is empty — the task appears to succeed but produces no artifacts. Verify that the outputs globs in turbo.json match the actual build output paths for each task. Run turbo run build --dry to preview what Turborepo will cache.
If package A depends on package B and package B depends on package A, pnpm will install but Turborepo’s topological sort fails. The build hangs or errors with a cycle detection message. Circular dependencies between packages indicate a design problem — extract the shared code into a third package that both A and B depend on. Use turbo run build --graph to visualize the dependency graph and spot cycles early.
Running turbo run dev across multiple apps launches multiple dev servers simultaneously. If they all default to port 3000, only the first one succeeds. Configure each app’s dev server with a unique port in its .env file or package.json script. Turborepo’s persistent: true flag in the dev pipeline keeps these long-running tasks alive but doesn’t handle port allocation — that’s the developer’s responsibility.