How we stopped copy-pasting a whole admin panel to every customer.
We build a white-label admin/CMS panel. Every customer gets the same core: users, roles, permissions, content management, multi-language support. On top of that, each customer gets a handful of features only they need. Say, a real-estate portal wants a listings module, or a logistics company wants shipment tracking. (I'll use these two fictional customers as the running example throughout.)
For years, our "architecture" for this was git clone. Every new customer meant a fresh fork of the whole panel. It works great, right up until you have ten forks and you fix a bug in the permission system. Now you get to port that fix ten times, by hand, into ten codebases that have quietly drifted apart. We were spending more time porting than building.
This is the story of how we collapsed those forks into one pnpm monorepo (one shared core, one thin app per customer), and specifically how we made TanStack Router work in a setup that its own GitHub issues suggest it can't handle. Spoiler: it can, as long as you respect one rule. It's in the title.
The constraints that shaped everything
Before drawing boxes, we wrote down what was actually fixed:
-
Each customer has their own backend, own database, own domain. So one app = one backend = one
VITE_API_URL. There is no shared tenancy to exploit. - Branding, panel language, and permissions already come from the backend at runtime (a settings endpoint, an i18n endpoint, JWT claims). The frontend shell is genuinely thin: in practice, one env variable.
- Bespoke features are real code, not config. The listings module is a full feature with its own types, services, hooks and pages. And it must never appear in another customer's bundle.
That last constraint killed the obvious alternative: a single deployed SaaS with feature flags. Feature flags hide UI; they don't keep a customer's bespoke code out of everyone else's JavaScript. With per-customer backends anyway, per-customer builds were the honest model. What we needed to fix was the duplication, not the separation.
The target shape
backoffice/ ← one git repo
├── packages/
│ └── core/ ← @bo/core, ALL shared panel code
│ └── src/
│ ├── features/ ← users, roles, content, i18n, ...
│ ├── shared/ ← ui, lib, guards, hooks
│ ├── app/ ← layouts, providers, http, config
│ └── platform/ ← the seams: appConfig registry, bootstrap
├── apps/
│ ├── customer-a/ ← thin shell + bespoke `listings`
│ ├── customer-b/ ← thin shell + bespoke `shipments`
│ └── _template/ ← scaffold for the next customer
└── scripts/ ← new-app.mjs, sync-core-routes.mjs
What makes an app an app is exactly three things: which backend (VITE_API_URL), which modules are enabled, and which bespoke code it carries. Everything else is core.
Three mechanisms make this work. Two were easy. One ate a week of research.
Mechanism 1: core is a source package, no build step
@bo/core never gets compiled on its own. Its package.json exports raw .tsx source, and each app's Vite compiles it as if it were the app's own code. Turborepo calls this an "internal" or just-in-time package:
{
"name": "@bo/core",
"exports": { "./*": "./src/*" }
}
Apps depend on it with "@bo/core": "workspace:*", which pnpm resolves to a symlink. The payoff is enormous for a small team: no versioning, no publish step, no stale builds. Edit a core component and it hot-reloads instantly inside whichever customer app you're running.
The accepted tradeoff: a type error in core surfaces in the app's typecheck. We consider that a feature. It fails at the first pnpm build instead of at publish time.
Two Vite gotchas cost us an afternoon each, so here they are for free:
// apps/*/vite.config.ts
server: {
fs: { allow: [searchForWorkspaceRoot(process.cwd())] }, // dev server may serve ../../packages/core
},
// React Compiler runs via Babel. Its include must match core's source too,
// or core components silently skip compilation:
babel({ include: /\/src\/.*\.[jt]sx?$/, presets: [reactCompilerPreset()] })
Mechanism 2: core never knows who it's running for
Core must stay pure: no customer names, no hardcoded module lists. But core's layout needs the navigation, and core's guards need to know which modules are enabled. Classic dependency inversion. The app tells core who it is, once, at bootstrap.
// packages/core/src/platform/appConfig.ts
export interface AppConfig {
enabledModules: string[]
navigation: NavItem[]
}
let current: AppConfig | null = null
export function setAppConfig(config: AppConfig): void { current = config }
export function getEnabledModules(): string[] { /* throws if not set */ }
// apps/customer-a/src/app.config.ts, the customer's entire identity
export const appConfig: AppConfig = {
enabledModules: ['auth', 'users', 'roles', 'settings', 'content', /* … */ 'listings'],
navigation: [...coreNavigation, ...listingsNav],
}
A disabled module 404s at the route level (requireModule in beforeLoad) and disappears from the sidebar. There's a free future hiding in this seam, too: if the backend ever starts serving enabledModules, only the registry's source changes. A one-file migration.
Mechanism 3: the route tree, where it got hard
Here's what we refused to give up from TanStack Router: file-based routing, automatic code splitting, beforeLoad guard chains, and above all type-safe search params. Our entire URL-state pattern (table filters, pagination, everything) rests on validateSearch + Route.useSearch().
And here's the problem: TanStack Router generates a routeTree.gen.ts per project, and that generated tree does not survive a package boundary. If you go looking, you find a trail of pain: #1203 (sharing route trees across packages) and #4984 (virtual routes can't resolve package aliases in a monorepo; still open, unanswered, and its author eventually gave up and switched to React Router). There's a whole genre of blog posts titled some variation of "why TanStack Router may not be the best choice for monorepos".
We evaluated three ways to share routes and eliminated all three. One of them after building it:
- Virtual File Routes. The cleanest on paper: routes declared once, in core. In practice, #4984 is exactly our setup: package aliases in virtual route declarations resolve relative to the app directory and break. Unusable today.
-
Route-config stubs. Core exports
{ validateSearch, beforeLoad }objects, app route files spread them in. We built this. It worked, builds were green, and we reverted it the same day. Every route became an exercise in indirection: to understand one screen you now read two files in two packages. Not worth it. -
Code-based routing. The officially blessed monorepo pattern (a shared
routerpackage, arouterMapof components per app). It works, but it means abandoning file-based DX and automatic code splitting wholesale. Too much, too early.
The model that works: don't share the tree at all
The realization that unlocked everything: the route tree is the only thing that can't cross the package boundary, so let it be the one thing that doesn't. Share the pages, the guards, the search validators, the layouts. Duplicate the thin wiring.
Every app owns its physical src/routes/ directory and generates its own routeTree.gen.ts. A route file is deliberately boring, pure wiring:
// apps/customer-a/src/routes/_authenticated/users/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { requirePermission } from '@bo/core/shared/lib/permission'
import { requireModule } from '@bo/core/app/config/modules'
import { UsersPage } from '@bo/core/features/users/components/UsersPage'
export const Route = createFileRoute('/_authenticated/users/')({
beforeLoad: () => {
requireModule('users')
requirePermission('users.List')
},
validateSearch: (search: Record<string, unknown>) => ({
page: Number(search.page) || 1,
pageSize: Number(search.pageSize) || 10,
search: typeof search.search === 'string' ? search.search : '',
}),
component: UsersPage,
})
Duplicated wiring needs a source of truth, so ours lives in apps/_template: a complete app shell with every core route file. A ~30-line script (cpSync, essentially) propagates template routes to every app. It never touches bespoke routes, because they don't exist in the template. New customer = copy the template, set one env var, edit enabledModules.
Two supporting tricks keep the model honest:
-
Core pages never import app route files. That would be a reverse dependency; core can't know app paths. Instead, a core page binds to its route with
getRouteApi('/_authenticated/users/')at module level. File-based DX preserved, dependency arrow pointing the right way. -
The config files that also live per-app (
vite.config.ts) ride along in the same sync script. Anything duplicated needs a propagation story, or it will drift.
Proving it before merging
Claims are cheap, so before this hit main we verified the three promises the architecture makes.
Isolation. Bespoke code must not leak. After building everything:
grep -rl "api/Listing" apps/customer-a/dist # ✓ found
grep -rl "api/Listing" apps/customer-b/dist # ✗ nothing
grep -rl "api/Shipment" apps/customer-b/dist # ✓ found
grep -rl "api/Shipment" apps/customer-a/dist # ✗ nothing
Propagation. A change in packages/core/src/shared/ui shows up in every app's next build with zero manual steps. It's a symlink; there is nothing to forget.
No drift. diff -rq between the template's routes and each app's routes returns only the bespoke directories. The sync model holds.
Plus the boring-but-essential checks: one React instance across the workspace (pnpm hoisting plus aligned versions; the "invalid hook call" error is the monorepo rite of passage), a green tsc, zero-warning lint under a single root ESLint config, and the unit/schema test suite.
The honest ledger
Costs we accepted:
- Core route wiring exists N times (once per app), managed by a sync script and one team rule: route changes happen in the template, never in a single app. This is a process guarantee, not a compiler guarantee. We're fine with it at our scale; we'd want a CI drift check before we're fine with it at 20 apps.
- A new app's first build has a chicken-and-egg problem:
tscruns before Vite has ever generatedrouteTree.gen.ts. One documentedvite buildbootstraps it.
Deferred on purpose:
-
Turborepo. Two apps build in seconds; task caching solves a problem we don't have yet. The repo is already shaped for it (
apps/+packages/) when we do. - TypeScript project references. Core currently re-typechecks inside every app build. Annoying in principle, invisible in practice at this size.
-
Granular package exports.
"./*": "./src/*"offers zero encapsulation. Acceptable now, revisit when the team grows.
Watching: PR #7196, external directories in physical() virtual route mounts. If that lands, core route files could live once in packages/core and be mounted per app, and our sync script retires. The architecture doesn't change; one mechanism gets an upgrade.
Should you do this?
This model fits if: you ship one product to many customers who need separate builds (bespoke code, separate backends, white-labeling), you want core changes to reach everyone without porting, and you care about TanStack Router's type-safe DX enough to keep it.
It doesn't fit if: you have one app and just want shared libraries (that's the standard Turborepo starter, simpler than this), or your teams need to compose routes from many packages into one runtime app. That second case is a micro-frontend problem; the people unhappy with TanStack Router in monorepos are usually solving that one, and honestly, React Router may serve them better.
The rule that made it all work fits in one line, so here it is one last time:
In a monorepo, share your pages, your guards, your layouts, your validators. Share everything except the route tree.
Top comments (0)