Skip to main content

Turborepo Monorepo Guide: Managing Multi-App Projects

Master Turborepo for managing multi-app monorepos. Learn workspace configuration, task pipelines, caching, shared packages, and CI/CD optimization for large-scale projects.

E
ECOSIRE Research and Development Team
|March 19, 20269 min read1.7k Words|

Monorepos are not a trend — they're a pragmatic solution to a real problem: code sharing across multiple applications without the overhead of versioning and publishing internal packages.

Monorepos are not a trend — they're a pragmatic solution to a real problem: code sharing across multiple applications without the overhead of versioning and publishing internal packages. Turborepo 2.8 is the tool that makes TypeScript monorepos feel fast, and when combined with pnpm 10.28's workspace protocol, you get a development experience that scales from 5 developers to 50.

This guide covers the complete journey: from workspace configuration and build pipeline design through shared package patterns and CI caching strategies. We'll draw from a production monorepo with 5 apps, 7 shared packages, and build times under 3 minutes on CI.

Key Takeaways

  • Workspace packages must be pre-built to CommonJS via tsc before NestJS can consume them
  • turbo.json pipeline tasks can depend on upstream package builds with ^build
  • Never import from workspace packages using relative paths — use workspace protocol aliases
  • Remote caching dramatically cuts CI time — configure it from day one
  • pnpm workspaces are faster and more predictable than npm or yarn workspaces for monorepos
  • Filter with --filter to build/test only affected packages during development
  • TypeScript paths must be configured in both tsconfig.json and the bundler for IDE support
  • Turbo's persistent tasks (persistent: true) prevent dev servers from exiting

Repository Structure

A well-organized Turborepo separates applications from shared packages clearly. Applications are deployable; packages are libraries.

monorepo-root/
  apps/
    web/          — Next.js 16 frontend
    api/          — NestJS 11 backend
    docs/         — Docusaurus
  packages/
    db/           — Drizzle ORM + schema
    types/        — Shared TypeScript types
    validators/   — Zod schemas
    utils/        — Shared utility functions
    ui/           — Shared React components
    email-templates/ — React Email components
    config/       — Shared configuration (eslint, tsconfig)
  infrastructure/
    docker-compose.dev.yml
    nginx/
  turbo.json
  package.json    — Root workspace config
  pnpm-workspace.yaml

The infrastructure/ directory is not a package — it contains deployment configuration and is excluded from Turbo builds.


Root Configuration

The root package.json configures the workspace and defines scripts that Turbo orchestrates:

{
  "name": "ecosire-monorepo",
  "private": true,
  "packageManager": "pnpm@10.28.0",
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev --parallel",
    "test": "turbo run test",
    "lint": "turbo run lint",
    "type-check": "turbo run type-check",
    "db:push": "pnpm --filter @ecosire/db db:push",
    "db:studio": "pnpm --filter @ecosire/db db:studio",
    "db:migrate": "pnpm --filter @ecosire/db db:migrate"
  },
  "devDependencies": {
    "turbo": "^2.8.0",
    "typescript": "^5.4.0"
  }
}

The pnpm-workspace.yaml tells pnpm where to find packages:

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

turbo.json: The Build Pipeline

The turbo.json file is where Turborepo's power lives. It defines task dependencies, caching behavior, and which outputs to persist.

{
  "$schema": "https://turbo.build/schema.json",
  "ui": "tui",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["$TURBO_DEFAULT$", ".env*"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**", "build/**"]
    },
    "dev": {
      "dependsOn": ["^build"],
      "cache": false,
      "persistent": true
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "type-check": {
      "dependsOn": ["^build"]
    },
    "db:push": {
      "cache": false
    },
    "db:studio": {
      "cache": false,
      "persistent": true
    }
  }
}

Key concepts:

  • "dependsOn": ["^build"] — The caret (^) means "build all packages this task depends on first". When you run dev on the web app, Turbo automatically builds @ecosire/db, @ecosire/types, and all other dependencies first.
  • "persistent": true — Prevents Turbo from treating long-running dev servers as failures. Without this, dev tasks exit immediately.
  • "cache": false — For tasks where output changes aren't meaningful (database operations, dev servers).
  • outputs — Which files Turbo should cache. Be specific — caching .next/cache/** (Next.js build cache) wastes space.

Shared Package Setup

Shared packages are the core value proposition of a monorepo. Each package needs a package.json with build scripts and a TypeScript config.

{
  "name": "@ecosire/types",
  "version": "0.0.0",
  "private": true,
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "scripts": {
    "build": "tsc",
    "type-check": "tsc --noEmit"
  },
  "devDependencies": {
    "typescript": "workspace:*"
  }
}
{
  "extends": "../../packages/config/tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}

Workspace Protocol for Internal Dependencies

When apps depend on internal packages, use the workspace:* protocol — not ^0.0.0 or a version number:

{
  "name": "@ecosire/api",
  "dependencies": {
    "@ecosire/db": "workspace:*",
    "@ecosire/types": "workspace:*",
    "@ecosire/validators": "workspace:*",
    "@ecosire/utils": "workspace:*"
  }
}

The workspace:* protocol tells pnpm to link the local package directly instead of fetching from npm. This means changes to @ecosire/types are immediately available in apps/api after rebuilding (or instantly if you use TypeScript paths pointing to source).


TypeScript Configuration Inheritance

The packages/config package contains shared TypeScript configuration:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "CommonJS",
    "moduleResolution": "bundler",
    "strict": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}
{
  "extends": "../../packages/config/tsconfig.base.json",
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM"],
    "module": "ESNext",
    "jsx": "preserve",
    "paths": {
      "@/*": ["./src/*"],
      "@ecosire/db": ["../../packages/db/src"],
      "@ecosire/types": ["../../packages/types/src"]
    }
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "next.config.ts"],
  "exclude": ["node_modules"]
}

The paths configuration with direct src references means TypeScript IDE support (go-to-definition, auto-import) works without rebuilding packages during development.


NestJS CommonJS Requirement

NestJS 11 requires CommonJS modules. This creates a build order constraint: workspace packages must be compiled to CommonJS before NestJS can consume them. This is why the dev task in turbo.json has "dependsOn": ["^build"] — Turbo ensures all package dependencies are built first.

{
  "compilerOptions": {
    "module": "CommonJS",
    "moduleResolution": "node"
  }
}

Next.js uses ESModules and handles both. NestJS needs CommonJS. If a shared package outputs ESModules only, NestJS will fail with SyntaxError: Cannot use import statement in a module.

Solution: compile packages to CommonJS (the safe default), or use dual output with both "import" and "require" entries in the exports field.


Remote Caching with Turborepo

Turbo's local cache only helps on the same machine. For CI/CD, enable remote caching so every CI runner benefits from previous builds:

# Authenticate with Vercel (free for remote cache)
npx turbo login
npx turbo link

For self-hosted or enterprise setups, configure a custom remote cache:

{
  "remoteCache": {
    "enabled": true,
    "preflight": true
  }
}

Set these environment variables in CI:

env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: your-team-name
  TURBO_REMOTE_ONLY: false

With remote caching, a CI run that only changes the frontend app skips rebuilding the backend, database package, and all unchanged apps. In a 5-app monorepo, this can cut CI time from 8 minutes to under 2.


Development Workflow

Running all apps simultaneously for local development:

# Start everything (packages build first, then apps start in parallel)
pnpm dev

# Start specific app and its dependencies only
pnpm --filter @ecosire/web dev
pnpm --filter @ecosire/api dev

# Build specific package
pnpm --filter @ecosire/db build

# Run tests only for changed packages (compared to main branch)
pnpm turbo run test --filter="...[HEAD^1]"

The --filter flag is Turbo's killer feature for large monorepos. It understands the dependency graph — --filter @ecosire/web builds the web app AND all packages it depends on.


Common Pitfalls and Solutions

Pitfall 1: Stale build artifacts causing type errors

When you change a shared package but don't rebuild it, consuming apps see stale types. Fix:

# Clean all build outputs and rebuild
find . -name "dist" -not -path "*/node_modules/*" -exec rm -rf {} + 2>/dev/null
find . -name "*.tsbuildinfo" -not -path "*/node_modules/*" -delete 2>/dev/null
pnpm turbo run build

Pitfall 2: Circular dependencies between packages

Packages A and B cannot depend on each other. Circular dependencies in workspaces cause resolution errors and make caching unreliable. Extract the shared code to a third package C that both A and B import.

Pitfall 3: Node_modules hoisting conflicts

pnpm uses a stricter hoisting model than npm/yarn. If a package works locally but fails in CI, check if it's accidentally relying on a hoisted dependency from another package. Add explicit dependencies to the package.json that needs them.

Pitfall 4: Missing --frozen-lockfile in CI

Always use pnpm install --frozen-lockfile in CI. Without it, pnpm might update the lockfile and install different versions than your lockfile specifies, breaking reproducibility.


Frequently Asked Questions

Should I use Turborepo or Nx for a new monorepo?

Both are excellent choices. Turborepo is simpler to set up, has zero configuration overhead, and integrates naturally with pnpm workspaces. Nx has more advanced features: project graph visualization, code generation, and fine-grained affected detection at the file level. For most teams, Turborepo is the right starting point. Migrate to Nx if you need its advanced features.

How do I handle environment variables in a monorepo?

Keep a single .env.local at the monorepo root. Each app reads it using a path relative to its own __dirname. For NestJS: path.join(__dirname, '..', '..', '..', '.env.local'). For Next.js: Next.js automatically picks up .env.local files, searching up to the monorepo root. Never commit .env.local — add it to .gitignore.

How do I version and publish packages from a monorepo?

For internal packages ("private": true), no versioning is needed — use workspace:* protocol. For publishable packages, use Changesets (@changesets/cli) for version management and changelog generation. Changesets integrates with GitHub Actions for automated npm publishing on merge to main.

How does Turborepo handle task parallelism?

Turbo runs tasks in parallel by default when they have no dependencies on each other. It respects the dependency graph in turbo.json — tasks with "dependsOn": ["^build"] run after their upstream builds complete. You can control concurrency with --concurrency flag, but the default (number of CPU cores) is usually optimal.

What's the recommended way to share ESLint and Prettier configs?

Create a packages/config package with your base ESLint and Prettier configurations. Export them as plain objects or use the extends pattern. Each app/package imports the shared config using the workspace:* protocol. This ensures consistent code style across the entire monorepo without duplicating configuration.


Next Steps

Building and maintaining a production monorepo requires careful upfront architecture decisions that compound over time. ECOSIRE runs a 5-app, 7-package Turborepo monorepo in production, with sub-3-minute CI builds and zero configuration drift between apps.

If you're scaling a development team or migrating from a multi-repo setup, our engineering team can help you architect a monorepo that scales. Explore our development services to get started.

E

Written by

ECOSIRE Team

Technical Writing

The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.

ECOSIRE

Grow Your Business with ECOSIRE

Enterprise solutions across ERP, eCommerce, AI, analytics, and automation.