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: Runbuildin 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:
- Building
@repo/utilsfirst (no dependencies) - Building
@repo/uinext (depends on nothing internal) - 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:
- pnpm workspaces for package linking
- turbo.json for task configuration
- Shared packages in
packages/ - Apps in
apps/ - 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.