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.
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.
| Monorepo | Multi-repo (our shape) | |
|---|---|---|
| Deliverable boundary | Synthesized at package time | Already the repo |
| Atomic cross-project change | Yes, one commit | No — N pull requests |
| Dependency upgrades | One lockfile, one CI run | 113 lockfiles, 113 CI runs |
| "Where does this live?" | Obvious | Needs a generated index |
| CI cost per change | Whole graph, unless scoped | One project |
| Per-project deploy | Needs path filters | Native |
| Onboarding a new project | Add a folder | Create + 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:
| Thing | Count |
|---|---|
| Products in the catalog | 111 |
| Available (buyable) products | 110 |
| Premium × ready editions | 116 |
| Distinct template repositories | 113 |
| Products shipping 4 editions each | 2 |
| Storefront repositories | 1 |
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 auditbeats 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
| Mistake | Symptom | Fix |
|---|---|---|
| Hand-maintaining the repo index | New projects silently missing from fan-out jobs | Generate it, and re-generate it before every fan-out run |
| Treating a generated index as correct | A release packages the wrong source, with no error | Assert on it: unmapped rows and duplicate repos should fail the run, not be skipped |
| Version living in the storefront | Edited templates never ship; buyers get a stale zip | Version lives in each repo's package.json; the catalog is the released mirror |
| Multi-edition versions drifting | Two editions of one design at different versions | Lockstep — bump all edition repos to the same number |
| Cloning everything to answer one question | Hours and hundreds of GB for a version comparison | Fetch the one file over HTTP; clone only to package |
| Unbounded fan-out concurrency | Disk exhaustion mid-run, half the batch lost | Bounded concurrency, delete each workspace as it finishes |
| Choosing by code-sharing appetite | Layout fights the deliverable on every release | Choose 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.
