Skip to content
iD
InfoDive Labs
Back to blog
DevelopmentMonorepoArchitecture

Monorepo Architecture: Managing Multiple Projects at Scale

A complete guide to monorepo architecture using Turborepo and pnpm workspaces, covering project structure, dependency management, CI optimization, and team workflows.

October 31, 20246 min read

When your organization manages a web application, a mobile app, a shared component library, and several backend services, the question of repository structure becomes unavoidable. Monorepos, where multiple projects live in a single repository, have become the standard approach for teams that value code sharing, atomic changes, and unified tooling. But a monorepo done poorly is worse than separate repositories. The key is getting the structure, tooling, and workflows right from the start.

This guide walks through the practical steps for building and maintaining a production-grade monorepo.

Why Monorepos Work

The core advantage of a monorepo is not that all your code is in one place. It is that related changes across multiple packages can be made, reviewed, and deployed atomically. When you update a shared API client, the web app and mobile app that depend on it are updated in the same pull request. There is no coordination overhead, no version mismatch, and no "which version of the shared library does service X use?" confusion.

The benefits compound with scale:

  • Shared tooling: One ESLint config, one TypeScript config, one CI pipeline
  • Atomic changes: Refactors across package boundaries happen in a single commit
  • Simplified dependency management: Internal packages are always at the latest version
  • Consistent standards: Linting, formatting, and testing conventions apply everywhere
  • Easier onboarding: New developers learn one repository structure, not twelve

The tradeoffs are real too. CI pipelines must be smart enough to only build what changed, tooling setup is more complex initially, and repository size can become an issue over time. Modern tools solve these problems effectively.

Setting Up with Turborepo and pnpm

Turborepo combined with pnpm workspaces is the most productive monorepo setup for JavaScript and TypeScript projects today. Turborepo handles task orchestration and caching, while pnpm provides fast, disk-efficient package management.

Start with the workspace configuration:

// package.json (root)
{
  "name": "acme-monorepo",
  "private": true,
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint",
    "test": "turbo run test",
    "typecheck": "turbo run typecheck"
  },
  "devDependencies": {
    "turbo": "^2.0.0"
  }
}
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

A well-organized monorepo separates applications from shared packages:

acme-monorepo/
  apps/
    web/              # Next.js web application
    mobile/           # React Native app
    api/              # Express/Fastify backend
    admin/            # Internal admin dashboard
  packages/
    ui/               # Shared React component library
    config-eslint/    # Shared ESLint configuration
    config-typescript/# Shared tsconfig files
    database/         # Prisma schema and client
    api-client/       # Generated API client
    utils/            # Shared utility functions
  turbo.json
  pnpm-workspace.yaml
  package.json

Configuring Turborepo for Efficient Builds

The turbo.json configuration defines the relationships between tasks and how they should be cached. Getting this right is critical for build performance.

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json", "package.json"],
      "outputs": ["dist/**", ".next/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", ".eslintrc.*", "tsconfig.json"]
    },
    "test": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "test/**", "vitest.config.*"]
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json"]
    }
  }
}

The ^build syntax means "build all dependencies first." The inputs array tells Turborepo exactly which files affect each task, so it can skip tasks when those files have not changed. The outputs array tells it what to cache.

With this configuration, running turbo run build after changing only one package will rebuild only that package and its dependents, pulling everything else from cache. In a monorepo with 10+ packages, this regularly reduces build times from minutes to seconds.

Sharing Code with Internal Packages

The most valuable part of a monorepo is shared packages. A shared UI library, for example, ensures consistent components across all applications.

// packages/ui/package.json
{
  "name": "@acme/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    "./button": "./src/button.tsx",
    "./card": "./src/card.tsx",
    "./input": "./src/input.tsx",
    "./styles.css": "./src/styles.css"
  },
  "devDependencies": {
    "@acme/config-typescript": "workspace:*",
    "react": "^19.0.0",
    "typescript": "^5.5.0"
  }
}
// packages/ui/src/button.tsx
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cn } from "./utils";
 
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: "primary" | "secondary" | "outline" | "ghost";
  size?: "sm" | "md" | "lg";
}
 
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant = "primary", size = "md", ...props }, ref) => {
    return (
      <button
        ref={ref}
        className={cn(
          "inline-flex items-center justify-center rounded-lg font-medium transition-colors",
          variants[variant],
          sizes[size],
          className
        )}
        {...props}
      />
    );
  }
);
Button.displayName = "Button";

Consuming this in an application is a normal import:

// apps/web/src/app/page.tsx
import { Button } from "@acme/ui/button";
 
export default function Home() {
  return <Button variant="primary">Get Started</Button>;
}

The workspace:* protocol in pnpm ensures internal packages always resolve to the local version. No publishing, no versioning overhead, no staleness.

Optimizing CI for Monorepos

A naive CI pipeline that builds and tests everything on every push defeats the purpose of a monorepo. Use Turborepo's built-in filtering and remote caching to keep CI fast.

# .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
        with:
          fetch-depth: 2
 
      - uses: pnpm/action-setup@v4
        with:
          version: 9
 
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
 
      - run: pnpm install --frozen-lockfile
 
      - run: pnpm turbo run lint typecheck test build --filter="...[HEAD~1]"
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

The --filter="...[HEAD~1]" flag tells Turborepo to only run tasks for packages that changed since the last commit. Combined with remote caching via Vercel or a self-hosted cache, repeated builds across different CI runs and developers' machines share cached results.

Managing Dependencies and Versioning

Dependency management in monorepos requires discipline. Use pnpm's catalog feature to centralize shared dependency versions:

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
 
catalog:
  react: ^19.0.0
  react-dom: ^19.0.0
  typescript: ^5.5.0
  vitest: ^2.0.0

Individual packages reference catalog versions with catalog::

{
  "dependencies": {
    "react": "catalog:",
    "react-dom": "catalog:"
  }
}

This ensures all packages use the same version of shared dependencies, eliminating version conflicts and reducing node_modules size. When you upgrade React, you change one line in the workspace config, not ten package.json files.

For external publishing, tools like Changesets manage versioning and changelogs across packages. But for most internal monorepos, avoid versioning internal packages at all. Use "version": "0.0.0" and workspace:* references. Internal packages are always at HEAD.

Need help building this?

Our team specializes in turning these ideas into production systems. Let's talk.