A monorepo is easy to recommend and hard to justify. The sales pitch is always the same: one place for shared packages, atomic cross-cutting changes, consistent tooling. The cost shows up later as CI minutes, ownership ambiguity, and a workspace graph that nobody wants to explain to a new hire.
I reach for a monorepo when sharing code is already painful, not when I hope it might be someday.
The decision that actually matters
The useful question is not "should everything live in one repo?" It is: what is the unit of change?
If shipping a button style across two products regularly means opening two PRs, bumping a private npm package, and waiting for Dependabot noise, shared code is already coupling you. That is when a monorepo starts paying off. If each app has its own release cadence, its own secret set, and almost no shared UI, a monorepo mostly gives you merge conflicts and a heavier local install.
I use a simple checklist before moving:
- Shared surface exists today: design system, API client, auth helpers, lint/format config. Not "we might extract something later."
- Changes cross boundaries in the same PR often: more than once a month is enough signal for me.
- One primary language and toolchain: TypeScript + Node makes workspace tooling boring in a good way. Mixing mobile, Python ML, and a Cloudflare Worker under one roof is possible, but the shared tooling story gets thin fast.
- Someone owns the root: package manager, CI matrix, release rules. Without that owner, the monorepo becomes everyone else's problem.
If three of those are false, I stay in separate repos and extract a small package only when the copy-paste burns.
A layout that stays boring
When I do go monorepo, I keep the layout obvious:
textapps/ web/ # product SPA or Next app admin/ packages/ ui/ # primitives, not pages config-eslint/ config-typescript/ tsconfig/
Apps compose. Packages do not import apps. That rule sounds pedantic until the first "quick import from the web app" ships and you cannot test a button without booting the whole product.
Shared config belongs in packages too. Duplicating tsconfig.json fragments
across apps is how "strict" becomes tribal knowledge instead of policy.
A root package.json scripts block is the operator console:
json{ "name": "acme", "private": true, "scripts": { "dev": "pnpm --filter @acme/web dev", "lint": "pnpm -r lint", "typecheck": "pnpm -r typecheck", "build": "pnpm -r build" }, "packageManager": "pnpm@9.15.0" }
I pin packageManager so CI and laptops do not silently diverge. The monorepo
is only as coherent as its install story.
What usually goes wrong
Everything becomes "shared." Once packages/ exists, people dump utilities
there for convenience. Shared code needs a consumer and a reason. If only one
app imports it, it is not shared, move it back.
Package boundaries without API boundaries. Exporting src/** from a package
is not a public API. I export a small barrel (or explicit exports map) and
treat deeper imports as unsupported. That forces intentional APIs.
json{ "name": "@acme/ui", "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, "./styles.css": "./dist/styles.css" } }
CI that runs the world on every PR. Day one feels fine. Month six burns
money. Filter by changed packages early: pnpm --filter "...[origin/main]"
style selection, or whatever your runner supports, before the graph gets large.
Fake semver. Internal packages in a monorepo do not need semver busywork
unless you publish them. Prefer workspace protocol links
("@acme/ui": "workspace:*") and change them atomically. External publish is a
separate decision with its own release tooling.
When I deliberately choose not to
I avoid a monorepo when:
- Teams need isolated access control (different contractors, different trust boundaries) and the org cannot gate that with PATH ownership alone.
- Deploy pipelines are radically different and the shared root scripts become a lie.
- The only shared thing is a README tone.
A polyrepo with a well-versioned design system can be healthier than a monorepo
that nobody can pnpm install on a weak laptop.
What "done" looks like
A healthy monorepo answer is boring in demos:
- One clone, one install command.
- Apps depend on packages through declared exports.
- Lint, typecheck, and test can run scoped to what changed.
- A new engineer can draw the dependency graph on a whiteboard without opening Slack.
Monorepos are infrastructure for change frequency. If your change frequency across packages is low, the architecture is solution-looking for a problem you do not have yet.
Next I want to get concrete about the package manager layer, specifically pnpm workspaces, peer dependency traps, and the layout choices that keep installs predictable.