For most three-person startups, a single monorepo wins by default — not because monorepos are architecturally superior, but because at three people the expensive problem is coordination overhead, and one repo with atomic cross-cutting commits removes it. You should reach for polyrepo only when a specific service has a genuinely independent lifecycle: a different language, a different deploy cadence, or a hard security boundary. Everything below is about spotting those exceptions before they cost you.
The mistake I see small teams make is copying the repo strategy of a company 500x their size. Google's monorepo and Amazon's thousands of service repos are both answers to org-chart problems you do not have yet. At three people, the right question is not "what scales to 10,000 engineers" but "what lets us change the API and its consumer in one commit without a Tuesday-afternoon coordination meeting."
What actually breaks first at three people?
It's rarely build performance. With a small codebase, even a naive npm install && npm run build across everything finishes fast enough that caching is a nice-to-have, not a lifeline. What breaks first is change atomicity: you rename a field in the backend, and now you have to remember to update the frontend, the shared types, and the one background worker that also reads it.
In a monorepo, that's one commit, one pull request, one CI run that either goes green as a unit or fails as a unit. In a polyrepo, that same change becomes a choreography: merge the backend, publish a new shared-types package version, bump that version in two other repos, and pray nobody deploys the middle state. For a team that small, the polyrepo tax is paid in your own working memory, which is the scarcest resource you have.
The takeaway: at three people, optimize for atomic cross-repo changes, because the coordination cost of split repos lands entirely on humans who are already context-switching too much.
When is a monorepo the wrong choice?
Monorepos are the default, not the universal answer. Split a piece out when it has an independent lifecycle, meaning at least one of these is true:
- Different runtime or language with no shared code — a Go data pipeline that shares nothing with your TypeScript app gains little from living next to it, and drags a second toolchain into every clone.
- Independent deploy cadence with a hard contract — a public SDK you ship to customers on its own release schedule benefits from its own repo, its own semver, and its own issue tracker.
- A real security or access boundary — a repo containing infrastructure secrets handling or a component built by outside contractors who shouldn't see everything. Path-based permissions inside one repo are fragile; a separate repo is a real wall.
- Regulatory or audit isolation — a payments component that must be auditable in isolation.
Notice what's not on that list: "the frontend and backend are different things." Different concerns are exactly what a monorepo handles well with folders. The bar for splitting is an independent lifecycle, not merely a conceptual boundary.
The takeaway: split a repo when a component's release, runtime, or access boundary is genuinely independent — not just because it feels like a separate domain.
The decision table
| Factor | Lean monorepo | Lean polyrepo |
|---|---|---|
| Team size | 1–15 sharing code daily | Multiple teams owning distinct services |
| Cross-cutting changes | Frequent (API + client together) | Rare; services talk over stable contracts |
| Languages/runtimes | Mostly one ecosystem | Genuinely heterogeneous, no shared code |
| Deploy cadence | Everything ships together or near-together | Independent release schedules per service |
| Access control | Whole team trusts whole codebase | Hard boundaries (contractors, compliance) |
| CI complexity budget | Want one pipeline to reason about | Willing to run and maintain N pipelines |
| Shared code (types, utils) | Yes, and it changes often | Little, or stable enough to version |
If your row-by-row answers mostly land in the left column — which they will for a typical three-person product startup — you have your answer. A split here and there for a truly independent service is fine; a monorepo with one carved-out SDK repo is a completely reasonable shape.
The takeaway: score your real constraints against this table instead of importing a big company's org-driven layout.
How do you keep a monorepo fast without over-engineering it?
The failure mode of monorepos isn't the repo — it's a CI pipeline that rebuilds and retests everything on every commit until a green build takes twenty minutes. You fix that with task orchestration and caching, but you should add it when you feel the pain, not on day one.
A clean starting point for a JavaScript/TypeScript stack is workspaces plus a task runner. Here's a minimal pnpm workspace:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
// package.json (repo root)
{
"name": "startup-monorepo",
"private": true,
"scripts": {
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint"
},
"devDependencies": {
"turbo": "^2.0.0"
}
}
// turbo.json — only rebuild what changed, cache the rest
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"]
},
"lint": {}
}
}
For CI, the single highest-leverage move is to stop running work for untouched code. On GitHub Actions you can gate jobs on which paths changed:
# .github/workflows/ci.yml
on: [pull_request]
jobs:
changes:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.filter.outputs.api }}
web: ${{ steps.filter.outputs.web }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api:
- 'apps/api/**'
- 'packages/**'
web:
- 'apps/web/**'
- 'packages/**'
test-api:
needs: changes
if: ${{ needs.changes.outputs.api == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile && pnpm --filter api test
Two honest tool notes at the decision point. If you want a batteries-included monorepo with generators, dependency graphing, and affected-project detection out of the box, Nx is the one that gives you the most structure without assembling it yourself — at the cost of a heavier conceptual footprint and some lock-in to its plugin model. If you'd rather keep your existing scripts and just add caching and task scheduling on top, Turborepo is the one that stays out of your way, though it does less hand-holding and leaves project scaffolding to you. Both are real, both are maintained as of mid-2026, and either is overkill on a truly tiny repo where a couple of shell scripts still fit in your head.
The takeaway: add caching and affected-only CI when your build clock starts hurting — a three-person team can run a monorepo on plain workspace scripts for a surprisingly long time.
What does the polyrepo version actually cost?
If you do split, budget honestly for the glue. Shared code now has to be versioned and published, which means an internal package registry or a private npm scope, plus the discipline to bump and consume versions. A cross-cutting change becomes a sequenced set of PRs across repos, and you lose the ability to run one CI job that validates the whole system together. Tooling like a workspace-of-repos manifest or Git submodules exists, but submodules in particular are a well-known source of "why is my checkout in a detached weird state" confusion for small teams — I'd avoid them unless you have a specific reason.
None of this is fatal. It's just real work that a three-person team pays for out of the same limited attention budget it needs for the product. That trade only makes sense when the independence you gain is worth the coordination you lose.
The takeaway: polyrepo's cost is versioning and multi-repo coordination — real, ongoing, and paid in the same attention you'd rather spend shipping.
FAQ
Is a monorepo bad for build times?
Not inherently. Build times get bad when CI naively rebuilds everything on every commit. Adding a task runner with caching (Turborepo or Nx) and path-based CI filters keeps a monorepo fast well past three people. The repo layout is not what makes builds slow; unscoped CI is.
Can I mix monorepo and polyrepo?
Yes, and small teams often should. Keep your tightly-coupled app and its shared code in one monorepo, and split out only the pieces with a genuinely independent lifecycle — a public SDK, a differently-licensed component, or a service in another language. A "mostly monorepo with a couple of carve-outs" is a common, healthy shape.
Do I need Nx or Turborepo to start a monorepo?
No. Native package-manager workspaces (pnpm, npm, or Yarn) plus a few scripts run a small monorepo fine. Add a dedicated task runner when your build or CI time starts hurting, not before — introducing tooling you don't yet need is its own kind of overhead.
Bottom line
If you're three people shipping one product, start with a monorepo and use folders, not repos, to separate concerns — the coordination savings on atomic changes are worth more to you than any theoretical scaling benefit of splitting. Carve out a separate repo only for a component with a truly independent lifecycle: a different runtime with no shared code, an independent release cadence, or a hard security boundary. Reach for Nx if you want structure handed to you, or Turborepo if you want to keep your scripts and just add caching — but know that a tiny repo runs happily on plain workspaces until it doesn't. Revisit the decision when you cross roughly a dozen engineers or add a second team, because that's when the org-chart pressures that justify polyrepo actually start to appear.
Top comments (0)