Skip to content
lazy devs
5 min readLazy Devs

Monorepo or not? A pragmatic guide

Monorepo versus many repos is a tooling and team decision, not a religion. Here is how to pick the boring option that will not haunt you in eighteen months.

Every few months someone asks whether they should move everything into a monorepo, and the answer they want is "yes" or "no." The honest answer is "it depends on your team size, your release cadence, and how much shared code you actually have." So let us give you the decision tree we use instead of the vibes.

What a monorepo actually is (and is not)

A monorepo is one version control repository holding multiple projects: your web app, your API, a shared UI library, maybe a CLI tool. It is not a monolith. A monolith is a single deployable unit. You can have a monorepo that ships five independently deployed services, and you can have a monolith living in its own tiny repo. People conflate these constantly and then argue past each other.

The thing that makes a monorepo worth the trouble is shared code with shared lifecycle. If your frontend and backend share TypeScript types for API payloads, a monorepo lets you change the type in one commit and see both sides break in CI at the same time. That single property fixes a whole category of "the API changed and nobody told the frontend" bugs.

When the answer is clearly yes

Pick a monorepo when most of these are true:

  • You have shared types, validation schemas, or a design system used by more than one app.
  • The same small team owns multiple deployable pieces.
  • You want one pull request to span a feature that touches the API and the UI.
  • You are tired of version-bumping internal packages and waiting for the npm registry.

When the answer is clearly no

Stay with separate repos when:

  • Different teams own different services and rarely touch each other's code.
  • Your projects use different languages and toolchains with little overlap.
  • You have hard access-control requirements (a contractor should see the marketing site but not the billing service).
  • The projects genuinely have nothing in common except your company name.

Most small product teams sit squarely in the "yes" column, which is why monorepos got popular. But the failure mode is real: a monorepo with bad tooling is slower and more painful than two clean repos.

The part people skip: workspaces and task running

A monorepo without a package manager that understands workspaces is just a folder with extra steps. With npm, pnpm, or Yarn workspaces, internal packages resolve by name instead of by published version. Here is a minimal pnpm setup.

// package.json (repo root)
{
  "name": "studio",
  "private": true,
  "packageManager": "pnpm@9.12.0",
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev --parallel",
    "lint": "turbo run lint",
    "typecheck": "turbo run typecheck"
  },
  "devDependencies": {
    "turbo": "^2.1.0"
  }
}
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

Now apps/web can depend on a shared package without publishing anything:

// apps/web/package.json
{
  "name": "@studio/web",
  "dependencies": {
    "@studio/types": "workspace:*",
    "next": "^15.0.0"
  }
}

The workspace:* protocol tells pnpm to symlink the local packages/types directory. Change a type there, and apps/web sees it immediately. No build-publish-install dance.

The shared package itself stays small and honest:

// packages/types/src/invoice.ts
import { z } from "zod";
 
export const InvoiceStatus = z.enum(["draft", "sent", "paid", "void"]);
 
export const Invoice = z.object({
  id: z.string().uuid(),
  amountCents: z.number().int().nonnegative(),
  currency: z.string().length(3),
  status: InvoiceStatus,
  dueAt: z.coerce.date(),
});
 
export type Invoice = z.infer<typeof Invoice>;

Both the Next.js app and the Node API import Invoice from @studio/types. The API validates incoming requests with Invoice.parse(), and the frontend gets the inferred type for free. If you rename a field, TypeScript fails in both apps in the same CI run. That is the whole pitch.

Turborepo, caching, and not waiting forever

The second-biggest reason monorepos get a bad name is build time. If running tests means rebuilding all six packages every time, people will quietly move things back out. The fix is a task runner that understands the dependency graph and caches results.

Turborepo (shown above) reads a small config and only rebuilds what changed.

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

The ^build syntax means "build my dependencies first." The outputs list tells Turborepo what to cache. Touch only apps/web and the cached build of packages/types is reused. On CI, point that cache at remote storage and your second build of an unchanged package takes seconds. Nx gives you a similar graph-aware model if you prefer it; the principle is identical.

Without caching, a monorepo punishes you for growing. With it, CI time tracks the size of your change, not the size of your repo. That difference decides whether the monorepo survives contact with a busy team.

The honest costs

We are not selling you anything, so here are the parts that hurt.

CI gets more clever, which means more fragile. You need affected-package detection so a docs typo does not trigger a full deploy, the kind of CI/CD that small teams can actually maintain. That logic is worth writing, but it is logic you now own.

Tooling has to agree. One TypeScript version, one ESLint config, one Node version across the repo. That consistency is a feature until the day one app genuinely needs a different version and you are fighting the shared config.

Git history gets noisy. git log shows commits across every project. Path-scoped tooling (git log -- apps/web) helps, but newcomers feel the firehose.

Access control is coarse. Most Git hosts grant repo-level permissions. If you truly need per-project access boundaries, a monorepo works against you, and no amount of tooling fully fixes that.

None of these are dealbreakers for a single product team. All of them get heavier as headcount and project count climb, which is why companies past a certain size either invest seriously in custom tooling or split things apart.

A simple way to decide

Ask three questions. Do these projects share code with a shared release lifecycle? Does one team mostly own them? Do you want atomic cross-project changes in a single pull request? Two or three yeses, go monorepo and invest a day in pnpm workspaces plus Turborepo before you have ten packages, not after. Mostly noes, keep separate repos and use a private registry or a Git submodule for the rare shared bit.

The mistake is not picking the "wrong" structure. It is picking either one and then refusing to spend the half day on tooling that makes it actually work. A monorepo without caching and a multi-repo setup without a versioning story are both miserable in the same way: the boring infrastructure was skipped.

Takeaway

Default to a monorepo when a small team shares code and ships together, and set up workspaces plus a caching task runner on day one. Default to separate repos when teams and toolchains diverge. Either way, the structure is cheap; the tooling around it is what you are really choosing, so choose it on purpose.

If you want a second pair of eyes on your repo layout before it calcifies, that is the kind of boring-but-load-bearing work we like.

Related service

Web App Development

Production React and Next.js apps, built to last.

Learn more

Want this built right?

This is the work we do every day. Tell us what you are building and we will show you exactly how we would ship it.

hello@lazydevsagency.com