Skip to content
Back to Build Tutorials

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 checks

Example

// 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/web

Common 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.