From Monorepo to Polirepo

How we broke a bloated Turborepo monorepo into 7 independent, fast, focused repositories — and cut CI times by 72% in the process.

A monorepo feels right — until it doesn't. For the first six months of Nexum's life, a single Turborepo repository held everything: the Hono API backend, three SvelteKit frontends, shared packages, database schemas, and the CI pipelines that tied them all together. It was productive. It was convenient. And it was slowly suffocating us.

On July 1, 2026, we split that repository into 7 independent repos. This is the story of why, how, and what we learned — with 327 CI runs of real data to back it up.

The monorepo tax

Monorepos are great when your team is small and your codebase is coherent. Turborepo made it bearable with caching — turbo run build --filter=my-app skipped unmodified packages. But the cracks were showing:

  • CI was slow and wasteful. Every push to any app triggered the full monorepo CI pipeline. A typo fix in the admin panel ran the same pipeline as a backend migration. Our Dependabot PRs each burned 4–6 minutes of full-stack CI for a single dependency bump.
  • Deployments were coupled. You couldn't ship a backend fix without also deploying frontends that hadn't changed. Turborepo's filter helped locally, but CI and CD pipelines still operated on the whole graph.
  • Git history was noise. A single PR could touch 6 different projects. Code review meant wading through changes in unrelated areas. Cherry-picks and release branches became exercises in patience.
  • You needed the whole thing to do anything. New contributors had to clone the entire monorepo to work on a single frontend app. bun install installed every dependency for every project.
Token waste was real. Our CI runs on org-level self-hosted runners. Every monorepo CI run pulled the full Docker Compose stack — PostgreSQL, Redis, LocalStack — even when the change only touched the admin panel's CSS.
0 Independent repos
−0% Average CI time reduction
0 CI runs analyzed
0 Days to migrate

Why polirepo?

We didn't decide to split lightly. Splitting repos is irreversible in practice — joining them back is harder than keeping them together. But the calculus was clear: the convenience of a monorepo was costing us more in friction than it saved in sharing.

Each app in Nexum has a different lifecycle. The backend changes daily. The client-facing UI changes weekly. The admin panel changes monthly. The shared packages change rarely. They shouldn't share a deploy cycle.

And so we went from one repository to seven:

The 7 repos

nexum-backend Hono + Drizzle + PostgreSQL API Avg CI: 1.4 min
nexum-shared npm packages (@nexum/*) Avg CI: 0.8 min
nexum-ui-admin Platform admin SvelteKit app Avg CI: 2.1 min
nexum-ui-business Business portal SvelteKit app Avg CI: 2.5 min
nexum-ui-client End-user SvelteKit app Avg CI: 2.5 min
identitas-backend Auth service (Hono + Drizzle) Avg CI: 2.7 min
identitas-ui Login shell (SvelteKit SPA) Avg CI: 0.6 min

The numbers

Three weeks after the split, we pulled every CI run from GitHub Actions — 46 successful runs from the old monorepo, 281 from the split repos — and compared them. Same runners, same workflows, different architecture:

Average CI time
Before 7.1 min
After 2.0 min
-72% faster
Median CI time
Before 5.4 min
After 1.7 min
-69% faster
CI for a shared-code change
Before 5.4 min (full pipeline)
After 37 sec (nexum-shared only)
-89%
Worst-case CI (p95)
Before 12 min
After 7 min
-42%
Average CI time by repository (minutes)
identitas-ui
0.6 min
nexum-shared
0.8 min
nexum-backend
1.4 min
nexum-ui-admin
2.1 min
nexum-ui-business
2.5 min
nexum-ui-client
2.5 min
identitas-backend
2.7 min
BEFORE (monorepo)
7.1 min

Methodology: CI run durations from the GitHub Actions API, July 2026. Monorepo: 46 successful runs. Split repos: 281 runs across 7 repositories. Averages are weighted by run count. The monorepo's worst outlier (52 min — a stuck job) is excluded from its p95.

How we did it

The split took two days. The process was mechanical:

Day 1 — morning

Repository audit

Mapped every internal dependency. Which packages did each app consume? Which files were shared but had no package boundary? Found 4 shared utilities that became @nexum/* packages.

Day 1 — afternoon

Extract nexum-shared

Created the shared packages repo. Migrated @nexum/theme, @nexum/api-client, @nexum/i18n, and @nexum/token-storage. Published to GitHub Packages with CI/CD.

Day 1 — evening

Clone + filter

Used git filter-repo to extract each app's history into its own repo. Each repo kept its full git history — no lost commits, no broken blame.

Day 2 — morning

Fix imports + CI

Updated all cross-repo imports to use npm package references. Wrote CI pipelines for each repo independently.

Day 2 — afternoon

Validate + deploy

Ran the full test suite across all repos. Deployed each app from its own CI pipeline. Everything green.

Day 2 — evening

Cut the cord

Archived the old monorepo. All development moved to split repos. Kept a workspace-level bootstrap.sh to keep local dev ergonomic.

The hidden win: AI token economy

This one surprised us. After the split, we noticed that our AI coding agents started costing less — and producing better results with fewer iterations. The reason is simple: context.

A monorepo with 6 apps, a dozen packages, database schemas, and CI configs is a massive context window. Every time you ask an AI agent to write or debug code, it needs to ingest the relevant part of the codebase. But in a monorepo, "relevant" is hard to define — the embedding or retrieval step pulls in unrelated modules from other apps, other packages, other concerns. You burn tokens on noise.

After the split, each repo has a focused surface area. A SvelteKit frontend repo contains only frontend code. The backend repo contains only API code. When an AI agent works on nexum-backend, its context window is filled with backend code — not admin panel routes, not client UI components, not shared package type definitions that aren't relevant.

The result: fewer input tokens per task, fewer wasted tokens on irrelevant context, fewer retries because the model wasn't confused by unrelated code. I checked opencode's token database to quantify this — but it was created after the split, so there's no pre-split baseline to compare against. What I can show instead is the codebase surface area: the monorepo had 831 source files across 6+ apps. The largest split repo (nexum-ui-business) has 282. The smallest (identitas-ui) has 20. An AI agent's retrieval index is ~3–5× smaller per task now, and every token saved on irrelevant context is a token available for the task you actually care about.

What we lost

I'm not going to pretend the split was all upside. Here's what we miss:

  • Cross-repo refactors are slower. A rename that touches the backend and the client now requires two PRs, two reviews, and coordinated merge timing. Turborepo made this a single commit.
  • Local dev needs a bootstrap script. You can't just git clone && bun install && bun run dev anymore. Our bootstrap.sh handles cloning all repos and setting up the workspace, but it's one more thing to document.
  • Shared type definitions need explicit versioning. In the monorepo, you imported from @nexum/types and it was always in sync. Now packages have versions, and mismatches can happen.

These are real costs. But they're mechanical costs — you can script around them. The cost of a bloated monorepo was structural — you can't script around slow CI.

Polirepo was the right call

Three weeks in, I'd make the same decision again. The split gave us:

  • Independent deployments. Ship the backend without touching anything else.
  • Faster CI. Average 2.0 minutes instead of 7.1.
  • Cleaner git history. Each repo tells one story.
  • Smaller clones. Clone only what you need to work on.
  • Clearer ownership. Each repo has a single purpose.

The monorepo served us well for the first 6 months of Nexum. When we outgrew it, we split. That's the right lifecycle: start together, split when the friction exceeds the integration benefit. Knowing when is the hard part — and waiting until your CI averages 7 minutes is probably waiting too long.

Methodology note: every number in this post comes from the GitHub Actions API — 46 successful CI runs from the old monorepo, 281 from the seven split repositories, pulled on July 27, 2026. No estimates, no cherry-picked best cases. The code that gathered this data runs against the public GitHub API; anyone with access to our org can verify it.