Deep Dive

Turborepo Deep Dive: Building a Production Monorepo

A comprehensive guide to setting up a TypeScript monorepo with Turborepo—covering workspace configuration, caching, pipelines, and deployment strategies.

Monorepos let you manage multiple packages and applications in a single repository. Done right, they enable code sharing, atomic changes, and simplified dependency management. Done wrong, they become slow, confusing messes.

Turborepo makes monorepos fast. Really fast.

This guide walks through building a production-ready TypeScript monorepo from scratch, covering the patterns and practices that actually matter.


Why Turborepo?

Before diving in, let’s understand what Turborepo solves.

The Problem: In a monorepo, running npm run build across 10 packages means running 10 build commands. Most of them probably didn’t change since last time. That’s wasted time.

Turborepo’s Solution: Intelligent caching and task orchestration. If a package hasn’t changed, Turborepo skips rebuilding it. If packages depend on each other, Turborepo runs them in the correct order automatically.

The result: 5-minute builds become 30-second updates.

Turborepo vs Nx

Both are excellent. Key differences:

  • Turborepo: Simpler, faster to set up, less configuration
  • Nx: More features, plugin ecosystem, steeper learning curve

For most TypeScript projects, Turborepo hits the sweet spot of power and simplicity.


Project Structure

Here’s the structure we’ll build:

my-monorepo/
├── apps/
│   ├── web/              # Next.js frontend
│   └── api/              # Express backend
├── packages/
│   ├── ui/               # Shared React components
│   ├── utils/            # Shared utilities
│   └── config/           # Shared configs (TypeScript, ESLint)
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
└── tsconfig.json

apps/: Deployable applications packages/: Shared libraries used by apps (and each other)


Initial Setup

1. Create the Monorepo

mkdir my-monorepo && cd my-monorepo
pnpm init

We’re using pnpm for its excellent workspace support and disk efficiency. npm and Yarn work too.

2. Configure pnpm Workspaces

Create pnpm-workspace.yaml:

packages:
  - "apps/*"
  - "packages/*"

This tells pnpm that any folder in apps/ or packages/ is a workspace package.

3. Install Turborepo

pnpm add -D turbo

4. Create turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["build"]
    }
  }
}

Key concepts:

  • ^build: Run build in dependencies first (topological order)
  • outputs: Folders to cache (speeds up subsequent runs)
  • cache: false: Don’t cache dev servers (they never “complete”)
  • persistent: true: Keep running (for dev servers)

5. Add Root Scripts

Update package.json:

{
  "name": "my-monorepo",
  "private": true,
  "scripts": {
    "build": "turbo build",
    "dev": "turbo dev",
    "lint": "turbo lint",
    "test": "turbo test"
  },
  "devDependencies": {
    "turbo": "^2.0.0"
  }
}

Creating Shared Packages

Utils Package

mkdir -p packages/utils/src

packages/utils/package.json:

{
  "name": "@repo/utils",
  "version": "0.0.0",
  "private": true,
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  }
}

packages/utils/src/index.ts:

export function formatDate(date: Date): string {
  return date.toISOString().split('T')[0];
}

export function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/\s+/g, '-')
    .replace(/[^\w-]+/g, '');
}

packages/utils/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "declaration": true,
    "outDir": "./dist",
    "strict": true
  },
  "include": ["src"]
}

UI Package (React Components)

mkdir -p packages/ui/src

packages/ui/package.json:

{
  "name": "@repo/ui",
  "version": "0.0.0",
  "private": true,
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  },
  "dependencies": {
    "react": "^18.0.0"
  },
  "devDependencies": {
    "@types/react": "^18.0.0",
    "typescript": "^5.0.0"
  },
  "peerDependencies": {
    "react": "^18.0.0"
  }
}

packages/ui/src/Button.tsx:

import React from 'react';

interface ButtonProps {
  children: React.ReactNode;
  variant?: 'primary' | 'secondary';
  onClick?: () => void;
}

export function Button({ 
  children, 
  variant = 'primary', 
  onClick 
}: ButtonProps) {
  const baseStyles = 'px-4 py-2 rounded font-medium';
  const variants = {
    primary: 'bg-blue-500 text-white hover:bg-blue-600',
    secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
  };

  return (
    <button 
      className={`${baseStyles} ${variants[variant]}`}
      onClick={onClick}
    >
      {children}
    </button>
  );
}

packages/ui/src/index.ts:

export { Button } from './Button';

Creating Applications

Next.js Web App

mkdir -p apps/web
cd apps/web
pnpm create next-app . --typescript --tailwind --eslint

Update apps/web/package.json to use shared packages:

{
  "name": "@repo/web",
  "dependencies": {
    "@repo/ui": "workspace:*",
    "@repo/utils": "workspace:*",
    "next": "^14.0.0",
    "react": "^18.0.0"
  }
}

Now you can import shared code:

// apps/web/app/page.tsx
import { Button } from '@repo/ui';
import { formatDate } from '@repo/utils';

export default function Home() {
  return (
    <main>
      <h1>Today is {formatDate(new Date())}</h1>
      <Button>Click me</Button>
    </main>
  );
}

Express API

mkdir -p apps/api/src

apps/api/package.json:

{
  "name": "@repo/api",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "build": "tsc",
    "dev": "tsx watch src/index.ts",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "@repo/utils": "workspace:*",
    "express": "^4.18.0"
  },
  "devDependencies": {
    "@types/express": "^4.17.0",
    "tsx": "^4.0.0",
    "typescript": "^5.0.0"
  }
}

apps/api/src/index.ts:

import express from 'express';
import { formatDate } from '@repo/utils';

const app = express();

app.get('/api/health', (req, res) => {
  res.json({ 
    status: 'ok', 
    date: formatDate(new Date()) 
  });
});

app.listen(3001, () => {
  console.log('API running on http://localhost:3001');
});

Install and Build

From the root:

# Install all dependencies
pnpm install

# Build everything (in correct order)
pnpm build

# Run all dev servers
pnpm dev

Turborepo handles:

  1. Building @repo/utils first (no dependencies)
  2. Building @repo/ui next (depends on nothing internal)
  3. Building apps last (depend on packages)

Understanding Caching

Run build twice:

pnpm build
# First run: builds everything

pnpm build
# Second run: "FULL TURBO" - everything cached

Turborepo hashes:

  • Source files
  • Dependencies
  • Environment variables
  • Previous outputs

If nothing changed, it replays the cached output instead of rebuilding.

Cache Invalidation

Change a file in @repo/utils:

pnpm build

Turborepo rebuilds:

  • @repo/utils (changed)
  • @repo/web (depends on utils)
  • @repo/api (depends on utils)

But NOT @repo/ui (doesn’t depend on utils).

Remote Caching

Share cache across your team and CI:

npx turbo login
npx turbo link

Now when CI builds, local developers get instant cache hits for unchanged packages. Team of 10? Everyone benefits from everyone else’s builds.


Filtering

Run commands for specific packages:

# Only build the web app (and its dependencies)
pnpm build --filter=@repo/web

# Only the API
pnpm build --filter=@repo/api

# All packages (not apps)
pnpm build --filter="./packages/*"

# Everything that changed since main
pnpm build --filter="...[main]"

Filtering is essential for large monorepos and CI optimization.


Shared Configuration

TypeScript Config

Create packages/config/tsconfig/base.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

Extend in packages:

{
  "extends": "@repo/config/tsconfig/base.json",
  "compilerOptions": {
    "outDir": "./dist"
  },
  "include": ["src"]
}

ESLint Config

Create packages/config/eslint/base.js:

module.exports = {
  extends: ['eslint:recommended'],
  rules: {
    'no-console': 'warn',
  },
};

CI/CD with GitHub Actions

.github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v2
        with:
          version: 8

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - run: pnpm install

      # Enable Turborepo remote caching
      - run: pnpm build
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

      - run: pnpm lint
      - run: pnpm test

With remote caching, CI gets cache hits from local development. First PR after a change might take 2 minutes; subsequent PRs that don’t touch that code take 20 seconds.


Deployment Strategies

Option 1: Deploy Everything

Simple but not always efficient:

pnpm build
# Deploy apps/web to Vercel
# Deploy apps/api to Railway

Option 2: Deploy Only Changed

Using Turborepo’s filtering:

# Check if web changed
turbo build --filter=@repo/web --dry-run

# If changed, deploy

Option 3: Docker per App

apps/api/Dockerfile:

FROM node:20-alpine AS builder
WORKDIR /app

# Copy workspace files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/api/package.json ./apps/api/
COPY packages/utils/package.json ./packages/utils/

RUN corepack enable && pnpm install --frozen-lockfile

COPY . .
RUN pnpm build --filter=@repo/api

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/apps/api/dist ./dist
COPY --from=builder /app/apps/api/package.json ./
COPY --from=builder /app/node_modules ./node_modules

CMD ["node", "dist/index.js"]

Common Patterns

Adding a New Package

mkdir -p packages/new-package/src
# Create package.json with @repo/new-package name
# Add to consuming packages: "@repo/new-package": "workspace:*"
pnpm install

Sharing Types

Create packages/types/index.ts:

export interface User {
  id: string;
  email: string;
  name: string;
}

export interface ApiResponse<T> {
  data: T;
  error?: string;
}

Import anywhere:

import type { User } from '@repo/types';

Running a Single Package Script

pnpm --filter @repo/web dev

Troubleshooting

”Package not found” Errors

# Ensure symlinks are created
pnpm install

# Check the package exists
ls packages/

Cache Not Working

# Clear local cache
turbo clean

# Rebuild
pnpm build

TypeScript Can’t Find Package

Ensure tsconfig.json has correct paths or that packages are built:

pnpm build --filter=@repo/utils

When to Use a Monorepo

Good fits:

  • Multiple apps sharing code
  • Frontend + backend in same repo
  • Component libraries with example apps
  • Microservices with shared types

Bad fits:

  • Unrelated projects (just use separate repos)
  • Teams that can’t coordinate releases
  • Projects with incompatible Node versions

Summary

A production Turborepo setup needs:

  1. pnpm workspaces for package linking
  2. turbo.json for task configuration
  3. Shared packages in packages/
  4. Apps in apps/
  5. Remote caching for team/CI efficiency

The investment pays off quickly. What starts as “I’m tired of copying code between repos” becomes a unified platform where improvements benefit all applications instantly.

Start simple, add complexity as needed, and let Turborepo handle the orchestration.

turborepo monorepo typescript pnpm workspaces nx alternative javascript monorepo