---
title: "Monorepo Architecture Guide for SaaS Startups: Setup to Scale"
author: "Nate Laquis"
author_role: "Founder & CEO"
date: "2027-11-17"
category: "Technology"
tags:
  - monorepo architecture
  - SaaS startup monorepo
  - pnpm workspaces
  - Turborepo setup
  - monorepo CI/CD
excerpt: "Monorepos give SaaS startups a massive velocity advantage, but only if the architecture is right from day one. This guide covers everything from folder structure to CI optimization to scaling past 50 packages."
reading_time: "14 min read"
canonical_url: "https://kanopylabs.com/blog/monorepo-architecture-guide-saas-startups"
---

# Monorepo Architecture Guide for SaaS Startups: Setup to Scale

## When a Monorepo Makes Sense (and When It Does Not)

Every SaaS startup eventually faces the same repo architecture question: do you keep your web app, API, mobile app, and shared libraries in separate repos, or consolidate them into one? The answer depends on your team, your product surface, and how much code you actually share across services.

A monorepo makes sense when you have two or more deployable applications that share types, validation logic, or UI components. If your SaaS has a Next.js frontend, an Express or Fastify API, and a shared Zod schema library, splitting those across three repos means triple the CI configuration, triple the versioning overhead, and constant friction when a schema change touches both the frontend and backend. In a monorepo, that change is a single commit and a single pull request.

A monorepo does not make sense if your services are written in entirely different languages with no shared code, if your team is distributed across organizations with separate access controls, or if you are a solo founder with one deployable app and no shared packages. In those cases, a polyrepo is simpler and introduces less tooling overhead.

Here is the honest rule of thumb: if you find yourself opening two pull requests for what should be one change, you need a monorepo. If your services are genuinely independent, with separate teams, separate deploy cycles, and zero shared code, stay with polyrepo. For most SaaS startups building a product with a web frontend, an API, and maybe a mobile app, monorepo wins by a wide margin.

![Code on a monitor showing monorepo project structure and imports](https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800&q=80)

## Recommended Folder Structure for SaaS Monorepos

Folder structure is the foundation of a maintainable monorepo. Get it wrong and you will spend months refactoring. Get it right and new developers can navigate the codebase on their first day. After building monorepos for dozens of SaaS startups, here is the structure we recommend:

### The Three Top-Level Directories

- **apps/** contains your deployable applications. Each subfolder is something that gets built and shipped independently: apps/web (your Next.js or Remix frontend), apps/api (your backend service), apps/mobile (React Native with Expo), apps/admin (internal admin panel), apps/docs (documentation site). Every folder in apps/ has its own Dockerfile or deployment configuration.

- **packages/** contains shared libraries consumed by one or more apps. These are never deployed on their own. Typical packages include packages/ui (shared React component library), packages/api-client (typed API client generated from your OpenAPI spec), packages/config (shared ESLint, TypeScript, and Prettier configs), packages/types (shared TypeScript types and Zod schemas), and packages/utils (shared utility functions).

- **tooling/** contains build scripts, code generators, CI helpers, and developer tooling. This is where you put your custom ESLint rules, database seed scripts, deployment scripts, and any codegen configuration. Keeping tooling separate from packages avoids polluting your shared libraries with dev-only dependencies.

### A Concrete Example

Here is what this looks like for a typical B2B SaaS product:

- **apps/web** (Next.js 15, deployed to Vercel)

- **apps/api** (Fastify with tRPC, deployed to Railway or Fly.io)

- **apps/worker** (background job processor, deployed to Railway)

- **packages/ui** (Radix + Tailwind component library)

- **packages/db** (Drizzle ORM schema and migrations)

- **packages/types** (Zod schemas and inferred TypeScript types)

- **packages/api-client** (typed tRPC client wrapper)

- **packages/config** (tsconfig, eslint, prettier configs)

- **tooling/scripts** (seed scripts, migration helpers)

The key principle: apps consume packages, packages consume other packages, but packages never import from apps. This one-way dependency flow prevents circular imports and keeps your build graph clean.

## Workspace Setup with pnpm

pnpm is the right package manager for monorepos in 2027. Yarn workspaces work, npm workspaces work, but pnpm is faster, uses less disk space, and enforces stricter dependency resolution that prevents phantom dependency bugs. Every monorepo we build uses pnpm.

### Initial Setup

Create a pnpm-workspace.yaml at the root of your repo. This file tells pnpm where to find your packages:

- **packages:** list "apps/*", "packages/*", and "tooling/*" as your workspace directories

- **Root package.json:** set "private": true (the root should never be published) and define workspace-wide scripts like "dev", "build", "lint", and "test"

- **Per-package package.json:** each app and package gets its own package.json with a name scoped to your org (e.g., @acme/web, @acme/ui, @acme/types)

### Cross-Package Dependencies

When apps/web needs to import from packages/ui, add "@acme/ui": "workspace:*" to apps/web's dependencies. The "workspace:*" protocol tells pnpm to resolve this dependency from the local workspace, not from npm. This is critical. Without it, pnpm will try to fetch @acme/ui from the registry and fail.

### Dependency Hoisting Strategy

pnpm's strict node_modules structure means packages can only access dependencies they explicitly declare. This is a good thing. It catches missing dependency declarations that would silently work with npm or Yarn due to hoisting. If a package uses "lodash" but does not list it in its own package.json, pnpm will throw a "module not found" error. Fix this by adding the dependency explicitly. Do not work around it with .npmrc shamefully-hoist settings unless you have a specific legacy reason.

For shared dev dependencies (TypeScript, ESLint, Prettier), install them at the root with "pnpm add -Dw typescript eslint prettier". This avoids duplicating dev tooling versions across every package and ensures the entire repo uses one version of each tool.

![Laptop with code editor showing workspace configuration files](https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=800&q=80)

## Turborepo vs Nx: Configuring Your Build Orchestrator

Your monorepo needs a build orchestrator that understands the dependency graph, caches task outputs, and parallelizes work. The two serious options are Turborepo and Nx. We have written a detailed [Turborepo vs Nx comparison](/blog/turborepo-vs-nx-monorepo), but here is the configuration-focused summary.

### Turborepo Configuration

Turborepo is configured via a single turbo.json file at the repo root. You define a "tasks" object where each key is a script name (build, test, lint, dev) and the value describes its dependencies and caching behavior. For example, the "build" task depends on "^build" (meaning each package's build runs after its dependencies' builds), and its outputs are ["dist/**", ".next/**"]. Turborepo hashes your source files, dependencies, and environment variables to generate cache keys. If nothing changed, replaying a cached build takes under a second.

The killer feature is remote caching. Connect to Vercel Remote Cache (or a self-hosted alternative) and your CI server reuses cached builds from your teammates' machines. A fresh CI run on a branch where only one package changed can complete in under 2 minutes for a 15-package repo.

### Nx Configuration

Nx uses nx.json for workspace-level configuration and optional project.json files per package. Nx's "affected" command is its standout feature for CI: "nx affected -t test" only runs tests for packages that changed relative to the base branch. Nx Cloud provides distributed task execution, splitting your CI across multiple machines automatically. This matters at scale, when a single CI runner cannot finish in a reasonable time even with caching.

### Which Should You Pick?

For startups with fewer than 20 packages and fewer than 10 developers, start with Turborepo. It is simpler to configure, has less conceptual overhead, and does the most important job (caching and parallelization) just as well. If you grow past 50 packages or need distributed CI execution, evaluate migrating to Nx. The migration is not trivial but it is well-documented.

## Shared Package Patterns That Actually Work

Shared packages are the whole reason monorepos exist. But poorly designed shared packages create more problems than they solve. Here are the patterns we use repeatedly in production SaaS monorepos.

### The UI Component Library (packages/ui)

Build your component library on Radix UI (or another headless library) with Tailwind CSS for styling. Export compound components that match your design system. Use Storybook in the package for isolated development and visual regression testing. The critical rule: your UI package should have zero business logic. It renders props and fires callbacks. All data fetching, state management, and business rules live in the consuming app.

### The Typed API Client (packages/api-client)

If you use tRPC, your API client is already type-safe across the monorepo. If you use REST, generate a typed client from your OpenAPI spec using openapi-typescript and openapi-fetch. Either way, the API client package gives every frontend app a single, typed interface to your backend. When an API endpoint changes, TypeScript catches every broken call site at build time, not at runtime.

### The Shared Types Package (packages/types)

Define your Zod schemas here and export both the schemas and the inferred TypeScript types. Your API uses the schemas for request validation. Your frontend uses them for form validation. Your database layer uses them as the source of truth for data shapes. One schema, used everywhere, eliminates an entire class of data mismatch bugs.

### The Config Package (packages/config)

Export shared TypeScript, ESLint, and Prettier configurations. Each app and package extends from these base configs. When you decide to enable a new ESLint rule or change a TypeScript compiler option, you change it once in packages/config and every package picks it up. This prevents configuration drift, where apps/web has different lint rules than apps/api and nobody notices for months.

### Anti-Pattern: The "Utils" Dumping Ground

Every monorepo eventually gets a "packages/utils" or "packages/shared" that becomes a junk drawer of unrelated functions. Resist this. If a utility is used by one package, it belongs in that package. If it is used by two, consider whether it is stable enough to warrant its own package. Only extract a shared utility when you have a clear, well-defined interface and at least two consumers.

## CI/CD Pipeline Optimization for Monorepos

Monorepo CI is fundamentally different from polyrepo CI. In a polyrepo, every push triggers a full build and test cycle for that repo. In a monorepo, you need to figure out what changed and only build and test the affected packages. Getting this right is the difference between a 3-minute pipeline and a 30-minute one. For a complete walkthrough of CI/CD fundamentals, check our [guide to setting up CI/CD for startups](/blog/how-to-set-up-cicd).

### Affected-Only Builds

Both Turborepo and Nx determine which packages are affected by a given change. In GitHub Actions, you compare the current commit against the base branch (main) and only run tasks for packages whose source files, dependencies, or configurations changed. With Turborepo, use "turbo run build test lint --filter=...[origin/main]" to restrict execution to changed packages and their dependents. This typically reduces CI time by 60 to 80 percent on branches that only touch one or two packages.

### Remote Caching in CI

Remote caching is the single highest-impact optimization you can make. When your CI runner hits a cache for a package that was already built by a teammate or a previous CI run, it skips the build entirely and replays the cached output. For a 15-package monorepo, this can reduce a cold CI run from 12 minutes to 3 minutes. Set up remote caching on day one. Vercel Remote Cache (for Turborepo) and Nx Cloud (for Nx) both offer free tiers that cover most startup workloads.

### Parallelizing CI Jobs

For larger monorepos (20+ packages), split your CI into parallel jobs using a matrix strategy. Run lint, type-check, and test as separate parallel jobs. Within each job, let your build orchestrator parallelize across packages. This turns a sequential 20-minute pipeline into a 5-minute one at the cost of more CI runner minutes. The time savings for your developers are worth the extra compute.

### Docker Builds in Monorepos

Building Docker images for monorepo apps requires care. You cannot just copy the entire repo into the Docker build context because it includes every app and package, bloating image sizes. Use pnpm's "deploy" command ("pnpm --filter @acme/api deploy ./out") to create a pruned, standalone copy of an app with only its dependencies. Copy that pruned output into your Docker image. Alternatively, use Turborepo's "prune" command to generate a minimal monorepo subset for a specific app.

![Startup office team collaborating on software architecture decisions](https://images.unsplash.com/photo-1504384308090-c894fdcc538d?w=800&q=80)

## Dependency Management, Versioning, and Code Ownership

As your monorepo grows, dependency management and code ownership become increasingly important. Without clear strategies, you end up with version conflicts, broken packages, and no accountability for shared code.

### Dependency Versioning Strategy

Use a single version policy for critical dependencies. React, TypeScript, and your ORM should be the same version across every package in the monorepo. Enforce this with pnpm's "overrides" field in the root package.json or use a tool like syncpack to detect version mismatches. When React 19 ships a breaking change, you upgrade once and fix every consumer in a single PR. In a polyrepo, that same upgrade requires coordinating across multiple repos and hoping nothing falls through the cracks.

For less critical dependencies (a charting library used by one app), pin the version in that app's package.json and upgrade at your own pace. Not every dependency needs repo-wide synchronization.

### Internal Package Versioning

For most SaaS startups, do not version your internal packages. Use "workspace:*" everywhere and treat the entire monorepo as one versioned unit. Internal versioning (packages/ui v1.2.3) adds overhead with no benefit when all consumers are in the same repo and deployed from the same CI pipeline. Versioning internal packages only makes sense if you publish them to npm for external consumers.

### CODEOWNERS for Accountability

Create a CODEOWNERS file at the repo root that maps directories to responsible teams or individuals. For example, "apps/web @frontend-team", "packages/db @backend-team", "packages/ui @design-system-team". GitHub automatically requests reviews from the owning team when a PR touches their code. This is essential once you have more than 5 developers. Without CODEOWNERS, shared packages accumulate changes that nobody reviews carefully, and quality degrades over time.

CODEOWNERS also clarifies who to contact when something breaks. If the CI pipeline fails because packages/types has a breaking change, the CODEOWNERS file tells you who introduced it and who is responsible for fixing it.

## Scaling Challenges and Migration Strategy

Monorepos work beautifully at small scale. The challenges show up as you grow past 30 packages, 15 developers, and a couple hundred thousand lines of code.

### Build Time Growth

Without caching, build times grow linearly with the number of packages. A 50-package monorepo can take 20+ minutes to build from scratch. Remote caching mitigates this, but cache misses (dependency upgrades, config changes) still trigger full rebuilds. Monitor your CI times weekly. If p50 CI time exceeds 10 minutes, investigate which packages are slow and whether you can split expensive tasks (type-checking, testing, linting) into parallel jobs.

### CI Cost Management

Monorepo CI uses more compute than polyrepo CI because every PR potentially triggers tasks across many packages. GitHub Actions charges per minute of runner time. A 15-developer team running 50 PRs per week with 8-minute CI runs burns roughly 400 runner minutes per week, which is well within GitHub's free tier. At 50 developers and 200 PRs per week, you are looking at 2,000+ minutes per week and should budget $50 to $150/month for CI compute. Remote caching pays for itself many times over by cutting billable minutes.

### IDE Performance

Large TypeScript monorepos can slow down VS Code and other editors. The TypeScript language server loads type information for the entire repo, which consumes memory and CPU. Mitigate this by using TypeScript project references, keeping tsconfig paths scoped to relevant packages, and using the "typescript.tsserver.maxTsServerMemory" setting to increase the memory limit. At extreme scale (500+ files per package), consider splitting into smaller packages so the language server loads less per project.

### Migrating from Polyrepo to Monorepo

If you are converting existing repos into a monorepo, here is the process that works:

- **Step 1:** Create the monorepo with the folder structure (apps/, packages/, tooling/) and configure pnpm workspaces and your build orchestrator.

- **Step 2:** Move one app at a time. Use "git subtree add" to bring in the repo with its full git history. Remap import paths and update the package.json name to use your org scope.

- **Step 3:** Extract shared code into packages. Start with types and validation schemas because they are the most obvious candidates for sharing. Move UI components next if both your web and mobile apps use React.

- **Step 4:** Unify CI/CD. Replace per-repo CI configurations with a single monorepo pipeline that uses affected-only builds. This is the most time-consuming step because every repo likely has its own CI quirks.

- **Step 5:** Deprecate the old repos. Set them to read-only, add a README pointing to the monorepo, and delete them after 30 days of no issues.

Budget 2 to 4 weeks for a migration involving 3 to 5 repos. The investment pays back within the first quarter through reduced context-switching, faster code reviews, and simpler dependency management.

### Real-World Monorepo Examples at Scale

Monorepos are not just a startup convenience. Google runs one of the largest monorepos in existence, with billions of lines of code and tens of thousands of developers. Vercel, the company behind Turborepo, uses a monorepo for its own platform. Stripe migrated to a monorepo to improve code sharing across its payment products. Shopify runs a massive Ruby on Rails monorepo for its core commerce platform. These companies prove that monorepos scale, but they also demonstrate that you need investment in tooling, CI infrastructure, and code ownership practices to make it work beyond the early stage.

For SaaS startups, the lesson is clear: start with a monorepo, invest in the right tooling early, and build habits around code ownership and dependency management before they become urgent problems. The architectural decisions you make in your first 6 months compound for years.

**Ready to set up your monorepo the right way?** [Book a free strategy call](/get-started) and we will design a monorepo architecture tailored to your product, team size, and deployment targets.

---

*Originally published on [Kanopy Labs](https://kanopylabs.com/blog/monorepo-architecture-guide-saas-startups)*
