Skip to content

Monorepo Architecture: Why and How

When a monorepo pays off versus when it is overhead, decision criteria from sharing UI and config without inventing a platform team.

4 min read
Monorepo
Architecture
Frontend
TypeScript
Tooling

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:

  1. Shared surface exists today: design system, API client, auth helpers, lint/format config. Not "we might extract something later."
  2. Changes cross boundaries in the same PR often: more than once a month is enough signal for me.
  3. 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.
  4. 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:

text
apps/ 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.