Monorepo Architecture for SaaS Startups: A Practical Guide

Monorepos organize your codebase for speed and consistency. Here is how to set one up with Turborepo.

Cover Image for Monorepo Architecture for SaaS Startups: A Practical Guide

A monorepo is not about putting everything in one repository. It is about organizing related code so that changes flow through your system consistently, builds stay fast, and your team stops wasting time on cross-repository coordination. For SaaS startups, the monorepo question is not "should we?" — it is "how do we set it up without the complexity tax that killed it at our last job?"

This guide covers the practical setup: Turborepo as the build orchestrator, package structure that scales, shared UI libraries, and the real-world lessons from shipping a monorepo with 18 internal packages. AeroCopilot runs on exactly this architecture, and our CTO refined the approach through years of enterprise monorepo work at Avenue Code, where multi-team coordination across large codebases was the daily reality.

Why Monorepos Win for SaaS

The SaaS use case for monorepos is compelling for three specific reasons:

1. Shared code without the npm publish dance. Your SaaS probably has a web app, maybe a marketing site, an admin dashboard, and eventually a mobile app or API. These share types, validation logic, UI components, and business rules. In a polyrepo setup, sharing means publishing to npm, versioning, and coordinating upgrades across repos. In a monorepo, shared code is imported directly. Change a type definition and every consumer updates instantly.

2. Atomic changes across boundaries. When you rename a database field, you want to update the schema, the API, the frontend types, and the tests in a single commit. Monorepos make this natural. Polyrepos make it a multi-day, multi-PR coordination problem.

3. Consistent tooling. One ESLint config. One TypeScript config. One test runner. One CI pipeline. Every package in your monorepo inherits the same standards. No more "this repo uses Prettier 2 and that repo uses Prettier 3" drift.

Turborepo: The Right Tool for Startups

Turborepo won the monorepo build tool race for startups because it does one thing well: it makes npm scripts fast and cacheable without requiring you to learn a new build system.

Compare the alternatives:

  • Nx is powerful but complex. It shines for large organizations with 50+ engineers. For a startup with 2-5 developers, the configuration overhead is not justified
  • Lerna is effectively deprecated as a standalone tool
  • Bazel is for Google-scale problems. If you are reading this article, you do not have Google-scale problems
  • Turborepo adds caching, parallelization, and dependency-aware task execution on top of your existing package.json scripts. Setup takes 30 minutes

Installation:

pnpm add turbo -D -w

Basic turbo.json:

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

The dependsOn: ["^build"] syntax means "build my dependencies before building me." Turborepo resolves the dependency graph and executes tasks in the correct order, parallelizing where possible. Remote caching means your CI does not rebuild unchanged packages.

Package Structure That Scales

Here is the package structure we use in production. AeroCopilot runs 18 packages following this pattern:

├── apps/
│   ├── web/              # Main Next.js application
│   └── e2e/              # Playwright E2E tests
├── packages/
│   ├── database/          # Prisma schema, migrations, client
│   ├── ui/                # Shared React components
│   ├── action-middleware/  # Server action middleware
│   ├── rbac/              # Role-based access control
│   ├── mailers/           # Email sending
│   ├── analytics/         # Analytics integration
│   ├── storage/           # File storage abstraction
│   ├── policies/          # Authorization policies
│   └── otp/               # OTP/2FA functionality
├── turbo.json
├── package.json
└── pnpm-workspace.yaml

Key principles:

  1. Apps consume packages, never the reverse. The dependency graph flows one direction. A package in packages/ should never import from apps/web
  2. Each package has a single responsibility. The database package owns the schema and Prisma client. The ui package owns components. No package does two things
  3. Packages export through barrel files. Every package has an index.ts that re-exports its public API. Internal implementation details stay internal
  4. Packages are independently testable. Each package has its own test suite that runs without needing the full application context

Shared UI: The Highest-ROI Package

The shared UI package is typically the first package you extract, and the one that delivers the most value over time. It ensures visual consistency across every surface of your product.

What belongs in the UI package:

  • Primitive components: Button, Input, Select, Dialog, Card
  • Layout components: Container, Stack, Grid
  • Feedback components: Toast, Alert, Skeleton
  • Typography components: Heading, Text, Label
  • Utility components: VisuallyHidden, Portal, Slot

What does not belong:

  • Business logic components (use feature-specific components in the app)
  • API calls or data fetching
  • Routing logic
  • Authentication state

A well-structured UI package looks like this:

packages/ui/
├── src/
│   ├── components/
│   │   ├── button/
│   │   │   ├── button.tsx
│   │   │   ├── button.test.tsx
│   │   │   └── index.ts
│   │   ├── input/
│   │   └── dialog/
│   ├── styles/
│   │   └── globals.css
│   └── index.ts
├── package.json
└── tsconfig.json

We use Tailwind CSS 4 with Base UI headless components as the foundation. This gives us unstyled, accessible primitives that we style with Tailwind utility classes. The result is components that are visually consistent, accessible by default, and trivially customizable.

The Database Package Pattern

The database package is the second most critical shared package. It owns your Prisma schema, migrations, seed data, and the generated Prisma client.

packages/database/
├── prisma/
│   ├── schema.prisma
│   ├── migrations/
│   └── seed.ts
├── src/
│   ├── client.ts      # Prisma client singleton
│   ├── queries/        # Reusable query functions
│   └── index.ts
├── package.json
└── tsconfig.json

Why this matters: When your database schema is in a shared package, every app and package that needs database access imports from the same source. Type safety flows from the schema through the Prisma client to every consumer. Change a field name in the schema and TypeScript errors appear everywhere that field is referenced — in the same build step.

The query functions layer is optional but valuable. Instead of writing raw Prisma queries in your app code, you encapsulate common queries in the database package. This creates a clean data access layer that can be tested independently and reused across apps.

CI/CD for Monorepos

Monorepo CI requires one critical capability: only running tasks for packages that changed. Without this, your CI time grows linearly with the number of packages.

Turborepo handles this with the --filter flag:

# Only build packages affected by changes since main
turbo build --filter=...[main]

# Only test the database package and its dependents
turbo test --filter=database...

A practical GitHub Actions workflow:

name: CI
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo build lint test typecheck --filter=...[origin/main]

The fetch-depth: 0 is essential — Turborepo needs full git history to determine which packages changed. The --filter=...[origin/main] flag restricts execution to affected packages only.

Remote caching through Vercel or self-hosted caching further reduces CI time. On a typical monorepo with 10-18 packages, remote caching cuts CI from 8 minutes to under 2 minutes for incremental changes.

Common Mistakes and How to Avoid Them

Mistake 1: Circular dependencies. Package A imports from Package B which imports from Package A. Turborepo will catch this at build time, but it is better to prevent it architecturally. Use dependency-cruiser or madge to visualize and enforce your dependency graph.

Mistake 2: Leaking internal types. Every package should have a clear public API defined in its barrel export. Do not export internal implementation types that consumers should not depend on.

Mistake 3: Inconsistent package.json scripts. Every package should have the same script names: build, dev, lint, test, typecheck. Turborepo runs tasks by name across the entire workspace. If one package calls its lint script check instead of lint, it gets skipped silently.

Mistake 4: Not pinning dependency versions. Use pnpm with its strict dependency resolution. Add a .npmrc with strict-peer-dependencies=true. This prevents the phantom dependency issues that plague monorepos with loose resolution. We discuss related architecture decisions that affect long-term maintainability.

Mistake 5: Premature package extraction. Not everything needs to be a separate package on day one. Start with apps/web and extract to packages/ only when you have a clear reuse case or a clear boundary. Over-extraction creates coordination overhead without corresponding benefit.

When to Start With a Monorepo

Start with a monorepo if any of these are true:

  • You are building a SaaS with more than one user-facing surface (web app + marketing site + admin dashboard)
  • You have shared business logic between a frontend and backend
  • You plan to build a component library for consistency
  • You want atomic deployments where related changes ship together

Do not start with a monorepo if:

  • You are building a single, standalone application with no shared code needs
  • Your team has zero experience with monorepo tooling and you are on a tight deadline
  • Your product is in pure exploration mode and the codebase changes radically every week

For most SaaS startups past the initial prototype phase, the monorepo pays for itself within the first month. The setup cost is 2-4 hours with Turborepo. The ongoing benefit is measured in developer-hours saved per week on coordination, consistency, and cross-cutting changes.

Start with Turborepo, pnpm workspaces, and three packages: your app, a shared UI library, and a database package. Grow from there as your product and team scale.