Nathaniel's blog
Back to posts

Turborepo: Scaling Your TypeScript Monorepo

Nathaniel LinMarch 2, 20267 min read3 views
Turborepo: Scaling Your TypeScript Monorepo

As your project grows from one app to multiple apps sharing packages, a monorepo becomes the natural architecture. Turborepo makes monorepos fast and manageable by orchestrating builds, caching results, and running tasks in parallel.

The Structure

A typical Turborepo project:


my-monorepo/
├── turbo.json           # Turborepo config
├── package.json         # Root package.json (workspaces)
├── pnpm-workspace.yaml  # pnpm workspace config
├── apps/
│   ├── web/             # Next.js frontend
│   └── api/             # Hono API server
└── packages/
    ├── ui/              # Shared component library
    ├── db/              # Database schema & client
    ├── eslint-config/   # Shared ESLint configs
    └── typescript-config/ # Shared tsconfig

Each folder under apps/ and packages/ is a workspace with its own package.json. They reference each other with "@repo/ui": "workspace:*".

Task Pipeline

turbo.json defines how tasks relate to each other:

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "check-types": {
      "dependsOn": ["^build"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

The ^build syntax means "build my dependencies first." When you run pnpm build, Turbo:

  1. Analyzes the dependency graph

  2. Builds @repo/db and @repo/ui first (no deps on each other → parallel)

  3. Then builds apps/web and apps/api (depend on packages → after step 2)

Caching

Turbo's killer feature is content-aware caching. It hashes:

  • Source files

  • Environment variables

  • Dependency versions

  • Task configuration

If nothing changed, it replays the cached output instead of re-running:

$ pnpm build
@repo/ui:build: cache hit, replaying logs...
@repo/db:build: cache hit, replaying logs...
apps/web:build: cache miss, executing...

In practice, rebuilding after a small change in one package takes seconds instead of minutes.

Remote Caching

Share the cache across your team and CI:

npx turbo login
npx turbo link

Now when a teammate pushes code, CI can reuse the cache from their local build. The remote cache stores compressed task outputs in the cloud.

Development Workflow

pnpm dev starts all apps simultaneously:

$ pnpm dev
@repo/db:dev: Watching for changes...
apps/api:dev: Server running on :4000
apps/web:dev: Ready on :3000

Turbo runs them in dependency order and keeps them running (persistent: true). Changes to @repo/ui trigger hot reload in apps/web automatically.

Filtering

Run tasks for specific packages:


# Only build the web app and its dependencies
pnpm turbo run build --filter=apps/web...

# Only lint changed packages since main
pnpm turbo run lint --filter='...[main]'

# Build a specific package
pnpm turbo run build --filter=@repo/ui

Sharing Code

Internal packages use TypeScript path aliases and exports:

{
  "name": "@repo/ui",
  "exports": {
    "./button": "./src/components/button.tsx",
    "./card": "./src/components/card.tsx"
  }
}

Consuming apps import directly:

import { Button } from "@repo/ui/button";
import { Card } from "@repo/ui/card";

No build step needed for internal packages during development — the consuming app's bundler compiles them directly.

When to Use a Monorepo

Good fit: Multiple apps sharing UI components, database schemas, or business logic. Cross-project refactoring. Consistent tooling and dependencies.

Bad fit: Unrelated projects. Teams that don't collaborate. Projects with vastly different tech stacks.

Turborepo is not about putting everything in one repo — it's about making shared code across related projects frictionless. The caching alone pays for the setup cost within the first week.

Share this post

Reactions

Turborepo: Scaling Your TypeScript Monorepo | Nathaniel's blog