The Monorepo vs Polyrepo Debate Settled for Startups
A monorepo is a single Git repository that contains multiple related packages or applications. A polyrepo (or multirepo) is a separate Git repository for each package or application. The debate between them is about developer experience, CI/CD complexity, and team coordination overhead. For startups and small teams, the monorepo wins on nearly every dimension: shared TypeScript types across frontend and backend, one CI pipeline to maintain, atomic commits across related changes, and no dependency versioning across repositories.
Written by Yashveer Singh, founder of Yashveer Labs.
What you actually need to know
- For a 1-10 engineer startup with a TypeScript frontend and TypeScript backend, use a monorepo. The shared types and atomic commits alone justify it.
- Turborepo + pnpm workspaces is the standard 2026 monorepo toolchain for JavaScript/TypeScript projects.
- The monorepo CI pipeline uses affected-package detection to run only what changed. One repo does not mean running all tests on every commit.
- The polyrepo is the right choice when the applications are genuinely independent (different teams, different languages, no shared types) -- not as a premature optimization for a future state.
- Monorepo limitations (slow Git operations, CI complexity) appear at 50+ engineers and hundreds of packages. Startups do not encounter them.
| Consideration | Monorepo | Polyrepo |
|---|---|---|
| Shared TypeScript types | Automatic (local packages) | Requires publishing + versioning |
| Cross-cutting changes | One PR, one review | Multiple PRs, coordination overhead |
| CI pipeline | One pipeline, affected-package detection | One pipeline per repo |
| Tooling consistency | One config for all packages | Per-repo config drift |
| Repository cloning | One clone | Multiple clones |
| Team independence | Shared codebase | Full isolation |
| Git performance at scale | Degrades at 100s of packages | Scales with team size |
The core argument
The polyrepo is often chosen for reasons that do not apply to the startup context: "we want team independence," "we want different deployment cadences," "we want to avoid coupling." These are real concerns -- at scale, with large teams, where true independence is needed. For a team of 3-6 engineers building one product, these concerns are hypothetical, and the polyrepo's costs are immediate.
The most concrete cost: shared TypeScript types across a frontend and backend in separate repositories require a publishing workflow. Change the User type in the backend? You must publish the types package, update the version in the frontend's package.json, install the new version, and then the TypeScript errors appear. In a monorepo, the same change is made once, and TypeScript immediately flags every downstream mismatch in the same repository. The feedback loop difference is 30 seconds (monorepo) versus 5-10 minutes (polyrepo).
Multiply this by every cross-cutting change over the product's lifetime and the developer experience cost of the polyrepo becomes significant. Multiply it by the number of times a junior engineer forgets to bump the shared types version and the production bug risk becomes measurable.
The monorepo also simplifies the question "what changed in this release?" Every change in a release is in one git log. Cross-repository coordination, changelog aggregation, and rollback are all simpler when the entire codebase is one repository.
The monorepo structure for a Next.js SaaS product
A typical monorepo for a SaaS with a Next.js frontend and an Express API:
`` my-product/ apps/ web/ -- Next.js application package.json src/ api/ -- Express API package.json src/ packages/ types/ -- Shared TypeScript types package.json src/index.ts ui/ -- Shared React components package.json src/ config/ -- Shared configuration (ESLint, TypeScript) package.json eslint-config.js tsconfig.json package.json -- Root workspace config pnpm-workspace.yaml turbo.json ``
The root pnpm-workspace.yaml: ``yaml packages: - "apps/*" - "packages/*" ``
The root turbo.json: ``json { "$schema": "https://turbo.build/schema.json", "tasks": { "build": { "dependsOn": ["^build"], "outputs": [".next/**", "dist/**"] }, "test": { "dependsOn": ["^build"] }, "lint": {}, "dev": { "cache": false, "persistent": true } } } ``
The ^build dependency means: run the build task in all dependency packages before running build in this package. This ensures packages/types is built before apps/web tries to import from it.
Shared types in practice
The packages/types package exports types shared between the frontend and API:
```typescript // packages/types/src/index.ts export interface User { id: string; email: string; name: string; plan: 'free' | 'starter' | 'pro'; createdAt: string; }
export interface ApiResponse<T> { data: T; error?: string; }
export interface PaginatedResponse<T> { items: T[]; total: number; page: number; pageSize: number; } ```
In the Next.js app: ```typescript import type { User, ApiResponse } from '@myproduct/types';
async function getUser(id: string): Promise<ApiResponse<User>> { const response = await fetch(/api/users/${id}); return response.json(); } ```
In the Express API: ```typescript import type { User, ApiResponse } from '@myproduct/types';
app.get('/api/users/:id', async (req, res) => { const user = await db.users.findById(req.params.id); const response: ApiResponse<User> = { data: user }; res.json(response); }); ```
When the User type changes (adding a field, changing a type), TypeScript flags every usage in both apps simultaneously. No publishing, no version coordination.
The CI/CD pipeline
Turborepo's --filter flag runs tasks only for packages affected by the current commit:
```yaml # .github/workflows/ci.yml name: CI on: [push, pull_request]
jobs: ci: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # needed for Turborepo affected-package detection
- uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: '20' cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Lint and type check run: pnpm turbo lint typecheck
- name: Test run: pnpm turbo test --filter=[HEAD^1]
- name: Build run: pnpm turbo build --filter=[HEAD^1] ```
The --filter=[HEAD^1] runs tasks only for packages that changed since the previous commit. A commit that only changes apps/web does not trigger the API's test suite. The CI time grows with the scope of the change, not with the total repository size.
For deployment: the web app deploys to Vercel automatically (Vercel has native monorepo support for Next.js). The API deploys to Railway when the apps/api package changes, using a conditional deployment step.
Common mistakes teams make with monorepos
- Not using a build orchestration tool. A monorepo without Turborepo (or Nx, or similar) has no build caching and runs all tasks on every commit. This produces slow CI times that grow linearly with the repository size. Build orchestration is not optional.
- Sharing too many packages. The
packages/uishared component library is appropriate; thepackages/button-specifically-for-the-checkout-pageis not. Over-granular package splitting creates version management overhead without benefit. Keep the number of packages small. - Not setting up TypeScript project references. Without TypeScript project references, the TypeScript compiler does not understand the monorepo's package dependency graph. This produces slower type checking and missing cross-package type errors. Configure
tsconfig.jsonwithreferencesto the packages the app depends on. - Using npm workspaces instead of pnpm workspaces. npm workspaces have stricter hoisting behavior that causes phantom dependency problems (importing a package that is available because it is a dependency of a dependency, but is not explicitly listed). pnpm's stricter module resolution prevents this class of problem.
- Putting secrets or environment-specific configuration in the shared
packages/config. Shared configuration is for linting rules, TypeScript settings, and development tooling -- not for API keys, database URLs, or environment variables. Each application's secrets remain in each application's environment configuration.
Where to start: a 3-step monorepo setup
Step 1: Initialize a pnpm workspace with the `apps/` and `packages/` directory structure. Move the existing applications into apps/web and apps/api. Create an empty packages/types with the shared types that are currently duplicated between the two apps.
Step 2: Add Turborepo and configure the `turbo.json` task graph. Run pnpm turbo build locally and verify the build order is correct (types build before the apps that use them). Enable remote caching with Vercel's Turborepo remote cache to share build artifacts between CI runs.
Step 3: Update the CI pipeline to use Turborepo's affected-package detection. Replace npm test with turbo test --filter=[HEAD^1]. Verify that a commit to apps/web does not trigger the apps/api test suite.
The Repository That Moves as One
Yashveer Singh. Founder of Yashveer Labs. Expert Tutorials runs as a monorepo with a Next.js app and a separate API service. The setup took one day -- converting the two separate repositories to a pnpm workspace with Turborepo and migrating the duplicated type definitions to a shared packages/types. The first benefit appeared in the same week: a breaking change to the user API response format was caught immediately by TypeScript in the frontend, before any build or deployment. In the polyrepo setup, that type mismatch would have required a publish-update-install cycle to surface. The monorepo catches it in the editor, in real time. That is the developer experience difference that makes the one-day setup cost clearly worthwhile.
Related reading
- The CI/CD Pipeline for a Solo Developer
- The Migration from Heroku: A Step By Step
- The Modern Web App Stack: A 2026 Survey
- The Modular Monolith: How to Buy Yourself Two Years
Frequently asked
The work I take and why
I take work that compounds. I do not take work that is rework with extra steps. Yashveer Singh, founder of Yashveer Labs. If the topic on this page is what you are dealing with, the question is not whether it can be solved. It can. The question is whether you want to solve it once or four times. I am the person who solves it once.
Posts that line up with this one.
- DevOps, Deployment, Infrastructure
Status Pages That Build Trust During Outages
A status page is your first line of communication when things break. Build one before the outage, not after.
- DevOps, Deployment, Infrastructure
Incident Severity Levels: A Practical Definition
Severity levels are the vocabulary your team uses to decide how fast to move and who to wake up. Here is a practical framework for defining them in a way that actually gets used during incidents.
- DevOps, Deployment, Infrastructure
Infrastructure as Code: Terraform vs Pulumi vs CDK
Terraform, Pulumi, and CDK all solve the same problem differently. The right choice depends on your team's language preferences, cloud targets, and how much you trust HCL. Here is a practical breakdown.
- DevOps, Deployment, Infrastructure
Kubernetes for Startups: When It Makes Sense, When It Does Not
Kubernetes is real infrastructure for real scale. Here is how to know if you are there yet.