TypeScript Monorepos with Turborepo and pnpm: The Setup That Actually Works
Build a scalable TypeScript monorepo with pnpm workspaces, Turborepo caching, shared packages, incremental builds, and production-ready Docker workflows.
Senior Developer

The promise of a monorepo is simple: one repository, shared code, unified tooling, atomic changes across packages. The reality, historically, was a fragile tangle of symlinks, build script order-of-operations problems, and TypeScript errors that only appeared in CI.
The tooling has caught up. In 2026, pnpm workspaces + Turborepo + TypeScript Project References is the stack that actually works — fast, well-documented, and production-tested at scale. Monorepos are now used by 63% of companies with 50+ developers, and for JavaScript/TypeScript teams managing between 5 and 50 packages, Turborepo is the practical choice.
This guide builds the full setup from scratch: workspace structure, shared packages, TypeScript project references, Turborepo pipelines, and Docker builds that work correctly with monorepo dependencies.
When a Monorepo Makes Sense
Before setting one up, be honest about whether you need it.
Use a monorepo when:
You have shared code (types, utilities, UI components) used by multiple apps
You make changes that span multiple packages atomically (API contract change + client update)
You are tired of version drift between a shared library and the apps that consume it
Your team works across packages regularly and context-switching between repos is friction
Stick with separate repos when:
Each service is truly independent with no shared code
Different teams own different services and need independent deployment cadences
You have fewer than 3 packages — the overhead is not worth it yet
The Structure
/
├── apps/
│ ├── api/ # Node.js Express API
│ └── web/ # Frontend (Next.js, etc.)
├── packages/
│ ├── types/ # Shared TypeScript types
│ ├── utils/ # Shared utility functions
│ ├── ui/ # Shared UI components (if applicable)
│ └── config/ # Shared ESLint, TypeScript configs
├── package.json # Root — workspace definition
├── pnpm-workspace.yaml
└── turbo.jsonStep 1 — Initialize the Workspace
# Install pnpm if you haven't
npm install -g pnpm
mkdir my-monorepo && cd my-monorepo
pnpm init# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
// package.json (root)
{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"test": "turbo test",
"typecheck": "turbo typecheck"
},
"devDependencies": {
"turbo": "latest",
"typescript": "^5.5.0"
},
"engines": {
"node": ">=20",
"pnpm": ">=9"
}
}pnpm add -D turbo -w # -w installs to workspace rootStep 2 — Shared Config Package
Start with the config package — everything else depends on it.
mkdir -p packages/config
cd packages/config
pnpm init
// packages/config/package.json
{
"name": "@myrepo/config",
"version": "0.0.1",
"private": true,
"exports": {
"./tsconfig": "./tsconfig.base.json",
"./tsconfig/*": "./tsconfig.*.json"
}
}// packages/config/tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution":"Node16",
"lib": ["ES2022"],
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}// packages/config/tsconfig.node.json — for Node.js packages
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}Step 3 — Shared Types Package
mkdir -p packages/types/src// packages/types/package.json
{
"name": "@myrepo/types",
"version": "0.0.1",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "tsc --watch"
},
"devDependencies": {
"@myrepo/config": "workspace:*",
"typescript": "^5.5.0"
}
}// packages/types/tsconfig.json
{
"extends": "@myrepo/config/tsconfig",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}// packages/types/src/index.ts
export interface User {
id: string;
email: string;
fullName: string;
role: 'user' | 'admin';
createdAt: Date;
}
export interface Order {
id: string;
userId: string;
status: 'pending' | 'paid' | 'shipped' | 'cancelled';
total: number;
createdAt: Date;
}
export interface ApiResponse<T> {
data: T;
meta?: Record<string, unknown>;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
hasNextPage: boolean;
nextCursor: string | null;
};
}Step 4 — The API App
mkdir -p apps/api/src// apps/api/package.json
{
"name": "@myrepo/api",
"version": "0.0.1",
"private": true,
"scripts": {
"build": "tsc",
"dev": "tsx watch src/server.ts",
"start": "node dist/server.js",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@myrepo/types": "workspace:*",
"@myrepo/utils": "workspace:*",
"express": "^4.21.0"
},
"devDependencies": {
"@myrepo/config": "workspace:*",
"typescript": "^5.5.0",
"tsx": "^4.0.0"
}
}// apps/api/tsconfig.json
{
"extends": "@myrepo/config/tsconfig",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
// Project references — tells TypeScript about dependencies
// Enables incremental builds — only rebuilds what changed
},
"references": [
{ "path": "../../packages/types" },
{ "path": "../../packages/utils" }
],
"include": ["src"]
}// apps/api/src/server.ts
import express from 'express';
import type { User } from '@myrepo/types'; // From the shared types package
import { formatDate } from '@myrepo/utils'; // From the shared utils package
const app = express();
app.use(express.json());
app.get('/users/:id', async (req, res) => {
const user: User = await getUserById(req.params.id);
res.json({ data: { ...user, createdAt: formatDate(user.createdAt) } });
});
app.listen(3000);Step 5 — Turborepo Pipeline
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"], // ^ means: build dependencies first
"outputs": ["dist/**"],
"inputs": ["src/**", "tsconfig.json", "package.json"]
},
"dev": {
"dependsOn": ["^build"], // Ensure deps are built before dev server starts
"cache": false, // Never cache dev
"persistent": true // Long-running process
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"],
"inputs": ["src/**", "tests/**"]
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": []
},
"lint": {
"outputs": []
}
}
}The ^build in dependsOn is the key: it tells Turborepo "before running this task, run build in all dependency packages." So when you build @myrepo/api, Turborepo automatically builds @myrepo/types and @myrepo/utils first. In parallel where possible.
Running tasks:
# Build everything in the right order
pnpm turbo build
# Build only the API and its dependencies
pnpm turbo build --filter=@myrepo/api
# Run dev servers for all apps in parallel
pnpm turbo dev
# Only run what changed since main branch
pnpm turbo test --filter=...[origin/main]
# Run with remote caching (Vercel)
pnpm turbo build --token=$TURBO_TOKEN --team=$TURBO_TEAMStep 6 — Docker Build for Production
The tricky part with monorepos and Docker is that COPY . . copies the entire monorepo into the build context. For a large monorepo this is slow and the resulting image is huge. Turborepo's prune command solves this — it generates a minimal subset of the monorepo with only the files needed for a specific app.
# Generate a pruned version of the monorepo for the API
npx turbo prune @myrepo/api --dockerThis creates an out/ directory with:
out/json/— just thepackage.jsonfiles (for dependency installation)out/full/— the complete pruned source
# apps/api/Dockerfile
FROM node:20-alpine AS base
RUN npm install -g pnpm
# Stage 1: Install dependencies (cached unless package.json changes)
FROM base AS deps
WORKDIR /app
# Copy only package.json files first — layer cache for dependency install
COPY out/json/ .
COPY pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
# Stage 2: Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY out/full/ .
RUN pnpm turbo build --filter=@myrepo/api
# Stage 3: Production image
FROM node:20-alpine AS production
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
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
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]Build it:
# From the repo root
npx turbo prune @myrepo/api --docker
docker build -f apps/api/Dockerfile -t myrepo-api .Step 7 — CI Pipeline
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2 # Needed for --filter=[HEAD^1] to work
- uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm turbo typecheck
- name: Lint
run: pnpm turbo lint
- name: Test
run: pnpm turbo test
- name: Build
run: pnpm turbo build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}With remote caching enabled, Turborepo skips tasks whose inputs have not changed since the last run — even across different machines. Remote caching can make builds up to 10x faster, which matters in CI where you pay per minute.
Adding a New Package
The pattern for adding any new shared package:
mkdir -p packages/logger/src
cd packages/logger
# Create package.json with name @myrepo/logger
# Create tsconfig.json extending @myrepo/config/tsconfig
# Write your code in src/
# Install it in apps that need it
cd ../../apps/api
pnpm add @myrepo/logger --workspaceThat's it. pnpm handles the symlink. Turborepo detects the dependency from package.json and includes it in the build graph automatically.
Comments (0)
Login to post a comment.