Monorepos With Turborepo and pnpm: A Setup Guide
Your codebase is growing. Shared types. Shared UI components. Multiple apps that deploy together. You’ve hit the monorepo moment.
Most teams wait until pain is unbearable. Don’t. A production-ready monorepo at 2 applications scales to 20 with barely any friction. Without one, you’ll spend weeks managing version mismatches and duplicated logic.
When You Actually Need a Monorepo
✔ Multiple apps share types, UI components, or config — a single source of truth beats npm private packages
✔ Apps deploy together — coordinating releases across repos is a nightmare
✔ Shared developer experience — linting, testing, and formatting rules in one place
⚠️ Not just because you have multiple projects. Monorepos have real overhead: circular dependency traps, CI complexity, tooling lock-in. If your apps are truly independent, keep them separate.
pnpm Workspaces Setup
pnpm is the only choice here. npm workspaces are barebones, yarn is legacy. pnpm’s strict phantom dependency prevention catches real bugs.
Create pnpm-workspace.yaml at the root:
packages: - "packages/*" - "apps/*"Your directory tree:
root/ packages/ ui/ # Shared components config/ # ESLint, tsconfig, etc types/ # Shared TypeScript types apps/ web/ # Next.js app api/ # Node.js server pnpm-workspace.yaml pnpm-lock.yaml turbo.jsonEach package has its own package.json:
{ "name": "@monorepo/ui", "version": "1.0.0", "type": "module", "exports": { ".": "./dist/index.js", "./components": "./dist/components.js" }}Install once at root: pnpm install. Dependencies are deduplicated globally. Apps depend on packages via:
{ "dependencies": { "@monorepo/ui": "workspace:*", "@monorepo/types": "workspace:*" }}The workspace:* protocol means “use the local version, always.”
Turborepo: The Task Graph Engine
Turborepo runs your build, lint, and test tasks only once per change, across all packages, respecting dependencies.
Create turbo.json at root:
{ "version": "1", "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"], "cache": true }, "lint": { "outputs": [] }, "test": { "outputs": ["coverage/**"], "cache": true }, "dev": { "cache": false, "persistent": true } }, "globalDependencies": ["tsconfig.json"]}What dependsOn: ["^build"] means: “Before building this package, build all its dependencies.”
If you change a file in packages/types, Turborepo rebuilds packages/types, then only apps/web and apps/api (because they depend on types). Everything else is skipped.
Run it:
turbo run build # Build everythingturbo run build --filter @monorepo/ui # Build only uiturbo run test --parallel # Run all tests at onceShared Packages Pattern
Config Package: One Source of Truth
packages/config/ src/ eslint.js prettier.js tsconfig.json package.jsonpackages/config/src/eslint.js:
export default { extends: ["eslint:recommended"], parser: "@typescript-eslint/parser", rules: { "no-var": "error", "prefer-const": "error" }};In apps/web/.eslintrc.cjs:
module.exports = require("@monorepo/config/eslint");One config. Twenty apps.
Types Package: Shared Data Structures
packages/types/ src/ index.ts package.jsonexport interface User { id: string; email: string;}
export interface ApiResponse<T> { data: T; error?: string;}Both apps/web and apps/api import from @monorepo/types. Never duplicate a type definition.
UI Package: Shared Components
packages/ui/ src/ Button.tsx Card.tsx package.jsonPublish to npm or keep internal. Both are valid. The monorepo doesn’t care.
Caching: The Real Win
Turborepo caching saves hours of rebuild time. On CI, a developer changes a button color. Without caching, lint/test/build everything. With caching, lint/test/build skips untouched packages.
Local cache: stored in .turbo/ (git-ignored)
Remote cache: Vercel or self-hosted
Self-Hosted Remote Cache
turbo loginturbo linkNow turbo run build checks the cache before running. If the exact inputs (source + deps + config) match a previous run, use the cached outputs instead.
On CI:
- run: pnpm install- run: turbo run build --api="$TURBO_API" --token="$TURBO_TOKEN"A PR that touches only docs? Cache hit. Zero builds.
Task Graph Visualization
turbo run build --graphShows which tasks depend on which. Catches your circular dependency mistakes before production.
Real Gotchas
Circular Dependencies
Package A imports from Package B imports from Package A. Webpack will bundle it, then fail at runtime.
Fix: Restructure. Put shared types in a third package with zero dependencies.
Version Mismatches
packages/ui requires react@18, but apps/web has react@17 locked. Turbo won’t catch this.
Fix: Specify peer dependencies explicitly. Add a pnpm.overrides in root package.json to pin versions globally:
{ "pnpm": { "overrides": { "react": "18.2.0" } }}CI Cache Setup
GitHub Actions cache invalidates on lock file change. That’s correct. But if your turbo.json changes, you need a new cache key.
- uses: actions/cache@v3 with: path: | ~/.pnpm-store .turbo key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}Move Fast
A monorepo done right means:
- One lint/test/build command for all apps
- Shared types and components with zero publish friction
- Task caching that skips untouched code
- A dependency graph you can actually see
Start here. Scale to 20 apps. Never look back.