Monorepo Setup
Structure a pnpm/Turborepo workspace for apps + packages with shared build cache.
Why
When you have 2+ apps or 4+ shared packages, a polyrepo forces you to publish private packages just to import them — a tax that kills iteration speed. A monorepo lets you refactor across app boundaries in a single PR, run affected tests only, and share a single lockfile that prevents dependency drift. The break-even point is surprisingly early: even two apps sharing a UI kit and a types package save a full day per week versus the publish-and-bump dance. The cost is tooling complexity, which Turborepo/Nx absorb for you.
When
When you have ≥2 apps or ≥4 shared packages. Takes 1-2 h to set up Turborepo. Skip for a single app with no shared code.
How
1. Init pnpm workspaces
- pnpm init at root; create pnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"
- Create apps/ and packages/ dirs
2. Add Turborepo
- pnpm add -Dw turbo
- Create turbo.json with a pipeline for build/dev/lint/test
- Each task declares "dependsOn" and "outputs" for caching
3. Create the first shared package (e.g. packages/ui)
- pnpm init inside it; name it "@myorg/ui"
- Add "exports" field so it's importable as "@myorg/ui/button"
- Build with tsup or tsc; point "main"/"types" at dist
4. Wire an app to consume it
- In apps/web: pnpm add @myorg/ui (uses workspace: protocol automatically)
- Import { Button } from "@myorg/ui" — changes hot-reload across packages
5. Add remote cache (optional but high-leverage)
- Enable Vercel Remote Cache or self-host with turbo-cache
- CI pulls cached build outputs → 5-10x faster PR checksExample
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"test": { "dependsOn": ["build"] },
"lint": {},
"dev": { "cache": false, "persistent": true }
}
}
# Run only what's affected by the change
turbo run build --filter=...[origin/main]
turbo run test --filter=@myorg/webCommon Problems
- ⚠Forgetting the '^build' dependency so packages build in parallel before their consumers — you get stale type errors.
- ⚠Not setting 'outputs' correctly, so Turbo caches nothing and you lose the speed benefit entirely.
- ⚠Using npm/yaml instead of pnpm — the hoisting model breaks workspace: protocol and you get phantom deps.
- ⚠Putting all packages in one tsconfig with path aliases instead of real package boundaries — refactors become global searches.
Best Practices
- ✓Use the workspace: protocol for all internal deps so pnpm never publishes a private package to npm by accident.
- ✓Keep one root lockfile — never commit per-package lockfiles, they cause version drift across the monorepo.
- ✓Tag every package with a consistent scope (@myorg/*) so internal vs external deps are visible at a glance.
- ✓Run 'turbo prune' before Docker builds to ship only the subset of packages the target app needs.