Skip to main content
ASoc
Comparison

Monorepo vs Multi-Repo: 110 Products Across 113 Repositories

Choose by the boundary the customer receives, not by code sharing. The cost is not merging — it is the generated index, and ours already had four bad rows.

The ASoc Team11 min read

Pick by what ships to the customer, not by how much code you share. One deliverable made of many parts is a monorepo. Many deliverables, each one downloaded and owned separately, is a multi-repo estate — and the cost you pay is not merging, it is discovery: knowing which repository a thing lives in, and keeping that index true.

This storefront is the second case, at an awkward scale. The catalog holds 111 products, 110 of them buyable, and those resolve to 116 premium × ready framework editions spread over 113 GitHub repositories — plus this one, which sells them. Here is what that actually costs, including the place our own index is wrong.

The axis the usual comparison misses

Most monorepo-vs-multi-repo writing is about one product built from many packages: an app, a design system, a shared config, a CLI. There, the argument is genuine — atomic cross-package commits and one CI run against everything are worth real money, and the counter-argument is tooling weight.

That is not this shape. A buyer of ASoc Haven downloads a zip and owns a project. They never see the other 109. Nothing is shared between products at runtime, because sharing would mean the buyer inherits a dependency on us, which is the one thing a template must not do.

So the deciding question is:

Does the boundary the customer receives already exist in the repository layout, or would you have to synthesize it at package time?

If it already exists, multi-repo is not a choice you are making — it is the shape of the product, and a monorepo would mean carving that boundary back out on every release.

MonorepoMulti-repo (our shape)
Deliverable boundarySynthesized at package timeAlready the repo
Atomic cross-project changeYes, one commitNo — N pull requests
Dependency upgradesOne lockfile, one CI run113 lockfiles, 113 CI runs
"Where does this live?"ObviousNeeds a generated index
CI cost per changeWhole graph, unless scopedOne project
Per-project deployNeeds path filtersNative
Onboarding a new projectAdd a folderCreate + configure a repo

Read that table by weight, not by row count. Two of those rows describe things we do weekly; the rest describe things we do rarely or never.

Our shape, in numbers

Every buyable edition is its own repository, its own Vercel project, and its own live preview URL. The counts, read out of the catalog and the release mapping:

ThingCount
Products in the catalog111
Available (buyable) products110
Premium × ready editions116
Distinct template repositories113
Products shipping 4 editions each2
Storefront repositories1

Two products — the flagship admin designs — ship React, Next.js, Vue and Angular editions of the same design, so they are four repositories each. Their versions move in lockstep: the product version is one number, and all four edition repos carry it.

The version's source of truth is each repo's package.json, not the storefront. Every template repository carries a small CLAUDE.md that says so, dropped in by a one-time setup script:

When you change this template, bump `package.json` `version` (SemVer)
before committing — the ASoc storefront reads this version to cut the
buyer download release.

...for a product with multiple framework editions, bump all of its
edition repos to the same version (lockstep).

That file is the multi-repo tax made visible. In a monorepo the convention would be enforced by one CI job. Here it has to be written into each repo, because each repo is edited alone.

The real cost is the index, and ours was already wrong

A multi-repo estate needs a map from "the thing" to "the repository". Ours is generated, because a hand-maintained one at 113 rows is a lie waiting to happen — the same failure mode that once left 24 products off every category page.

Resolving it is not trivial. A template's previewUrl host is a Vercel project domain, and a domain does not appear in the project object's alias arrays — it only shows on the per-project domains endpoint. So the mapping script lists every project, pulls each one's domains, and builds a host → org/repo map. That is roughly 130 API calls, which is why it needs a long-lived token rather than the CLI's short-lived one.

Then run it and read the output honestly. Of 116 editions:

  • 114 resolve to a repository.
  • 2 resolve to nothing. Two shop editions map to NONE — the host does not match any project domain we can see.
  • 2 resolve to the same repository. Two different landing products both point at one repo, which cannot be true of both.

That is 113 distinct repos for 116 shipping editions, and the gap is not tidy — it is four rows of bad data in the index that binds the estate together. Nothing failed. No build broke. The drift checker simply skips a NONE row, and the duplicate row would happily package the wrong project's source if someone released it without looking.

This is the multi-repo cost, stated precisely. It is not that merging is hard. It is that the mapping between the catalog and the code is a derived artifact that can be wrong, and in a monorepo that mapping is a directory name and cannot be.

The four operations that actually decide it

Ignore the abstract arguments and look at what the estate is asked to do.

1. "Which templates changed since their last release?" In a monorepo this is git log against a path. Here it is a script that fetches every repo's package.json from GitHub raw and compares to the catalog's released version — no clone, one HTTP request per repo:

async function repoVersion(repo: string): Promise<string | null> {
  const url = `https://raw.githubusercontent.com/${repo}/HEAD/package.json`;
  const res = await fetch(url);
  if (!res.ok) return null;
  const pkg = (await res.json()) as { version?: string };
  return pkg.version ?? null;
}

Multi-repo makes this a network problem instead of a filesystem problem, which is slower but not harder. Cost: low.

2. Package a release. Clone one repo at one ref, scan it, zip it, upload. Multi-repo wins outright — the clone is the deliverable, minus the git directory. In a monorepo you would be filtering a subtree and reconstructing a standalone package.json and lockfile. Cost: negative, i.e. multi-repo is cheaper.

3. Do something to all of them. Rebrand, re-screenshot, bump a dependency. This is where a monorepo genuinely wins and multi-repo is a fan-out loop. Ours clones, works, and deletes, with bounded concurrency — because node_modules for one template runs 300–900 MB, so peak disk is the concurrency limit, not 113 workspaces:

The clone is the expensive part on disk, so a product's workspace is removed as soon as its shots land — peak usage is CONCURRENCY workspaces, not 100+.

Cost: high, and it is the honest argument against our layout. We cannot patch a CVE across 113 lockfiles in one commit. We can only run a loop and hope every repo's CI agrees.

4. Find where a change goes. Multi-repo, with a correct index, is fine. Multi-repo with the index above is where the four bad rows bite. Cost: moderate, and entirely self-inflicted.

When a monorepo is straightforwardly the better answer

Not as a hedge — these are cases where our layout would be wrong:

  • The projects share code at runtime. A design system consumed by three apps belongs in one repo. Version skew between a package and its consumers is a class of bug that atomic commits delete outright.
  • Changes routinely cross projects. If a typical pull request touches two projects, multi-repo turns every feature into a release-ordering exercise.
  • One team owns everything and CI is cheap. The discovery cost is what multi-repo buys you nothing for.
  • You need one authoritative dependency graph. Security response is the clearest version of this. One npm audit beats 113.

Our estate fails all four tests, which is why the layout survives. Products never import each other, a change touches exactly one product, and each buyer wants an independent dependency graph — that is what they are buying.

Mistakes and how they show up

MistakeSymptomFix
Hand-maintaining the repo indexNew projects silently missing from fan-out jobsGenerate it, and re-generate it before every fan-out run
Treating a generated index as correctA release packages the wrong source, with no errorAssert on it: unmapped rows and duplicate repos should fail the run, not be skipped
Version living in the storefrontEdited templates never ship; buyers get a stale zipVersion lives in each repo's package.json; the catalog is the released mirror
Multi-edition versions driftingTwo editions of one design at different versionsLockstep — bump all edition repos to the same number
Cloning everything to answer one questionHours and hundreds of GB for a version comparisonFetch the one file over HTTP; clone only to package
Unbounded fan-out concurrencyDisk exhaustion mid-run, half the batch lostBounded concurrency, delete each workspace as it finishes
Choosing by code-sharing appetiteLayout fights the deliverable on every releaseChoose by the boundary the customer receives

Frequently asked questions

Is 113 repositories not obviously too many? It is exactly as many as there are things a customer can download separately. The number is a consequence of the catalog size, not a design decision — and it would be the same number of directories in a monorepo, with the extra job of carving each one back out at package time.

How do you keep dependencies current across all of them? Imperfectly, and this is the layout's weakest point. There is no single lockfile, so an upgrade is a fan-out job with 113 independent outcomes. If a critical advisory landed tomorrow, a monorepo would fix it in one commit and we would fix it in a loop. We accept that because a template buyer inherits and then owns their dependency graph — but do not pretend it is free.

Would a monorepo with per-project publishing not give you both? It would give you atomic changes plus a synthesis step. You would still need to emit a standalone project with its own lockfile and no workspace protocol references, and that emitted artifact is exactly what a repository already is. The synthesis is real work and it can silently produce a project that does not install outside the workspace.

What is the one thing worth stealing from this either way? Generate the index, then assert on it. Unmapped entries and duplicate targets are data-quality bugs that no build catches, and they sit quietly until the day something ships from the wrong source.

Templates that ship as their own repository

The two products below are the four-edition flagships from the census — one design, delivered as React, Next.js, Vue and Angular, versioned in lockstep. Buying either gives you every edition, and each arrives as a standalone project with its own dependency graph, which is the whole reason the layout looks the way it does.

Keep reading

Comparison12 min read

Next.js App Router vs Pages Router: Which to Use in 2026

The App Router is the default for new projects, but the Pages Router is not deprecated. Here is what actually changed and when migrating is not worth it.

Read more
Comparison8 min read

Next.js vs. Angular: A Function Call vs. a DI Container

Angular needs a DI container before a component can fetch anything; a Next.js Server Component just calls a function. Measured from this storefront's 334-page static build.

Read more