Most monorepo tooling exists to solve problems that appear at hundreds of engineers: remote build caches, dependency graphs across thousands of packages, code owners per directory. Adopting that machinery on a team of five buys you the maintenance and none of the benefit.
But the underlying reason for a monorepo — one commit changes the API and its consumer together — is worth just as much to five people as to five hundred. The question is how little you can adopt and still get it.
What earns its place
- One repository, one install, one lockfile. A checkout is complete: a new engineer runs one install and everything works, and a pull request that changes a shared type and every call site is one reviewable unit.
- Workspaces from the package manager you already have. npm, pnpm and yarn all do this. You do not need a build orchestrator to have workspaces.
- A shared types package, and almost nothing else shared. Types are the highest-value thing to centralise because they are what drift silently.
- Typecheck and lint across the whole repo in CI. This is what makes the arrangement pay: a change that breaks a consumer fails before merge, not in an unrelated deploy a week later.
What does not
- A build orchestrator, at first. Remote caching and task graphs solve minutes of CI time. On a small repo CI is already short. Add one when a full run genuinely hurts, and you will know when.
- Per-package versioning. Versioning internal packages against each other reintroduces exactly the coordination cost the monorepo was meant to remove. Internal packages track the commit; only published artefacts get versions.
- Directory-level ownership rules. With five people everyone reviews everything, and codifying ownership at that size mostly produces stale rules.
The shape
apps/
web/ the product
admin/ internal tooling
packages/
types/ domain types, shared everywhere
config/ tsconfig, eslint, tailwind presets
db/ schema, migrations, generated clientTwo rules keep it honest. Apps may depend on packages; packages may not depend on apps. And a package earns its existence when a second consumer appears — not in anticipation of one.
The failure mode to watch
The predictable way this goes wrong is a shared or common package that accumulates everything with no boundary. It starts as two helpers and becomes the thing every other package depends on, which means every change rebuilds and retests everything — the coupling you built the structure to avoid.
Name packages after what they are for. If you cannot name it precisely, it is not a package yet; it is two things waiting to be separated.
When to reconsider
This shape stops fitting when full CI runs get long enough that people start skipping them, or when two apps need genuinely different release cadences. Both are legible signals. Neither is a reason to adopt the machinery in advance.