ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls · Full content ownership
All systems operational
HomeTypeScript Monorepos with Turborepo and pnpm: The Setup That Actually Works

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.

ZyVOP
ZyVOP
Senior Developer
May 27, 2026
6 min read
TypeScript Monorepos with Turborepo and pnpm: The Setup That Actually Works
#TypeScript project references monorepo#Turborepo pnpm workspaces#Turborepo pipeline#monorepo Docker build#pnpm workspace tutorial#monorepo setup Node.js#TypeScript monorepo 2026

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

Step 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 root

Step 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_TEAM

Step 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 --docker

This creates an out/ directory with:

  • out/json/ — just the package.json files (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 --workspace

That'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.

ZyVOP
ZyVOP

Founder of Zyvop 🚀 | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub — and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropic’s Pricing Shock, Granite 4.2 Open‑Source Leap, and AI‑Powered Security & Policy Shifts

From Anthropic’s flagship model losing steam to IBM’s 512 K‑token Granite 4.2, plus a new wave of AI‑driven security exploits and policy alarms, this week’s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1