Replies: 1 comment
-
|
Nice job @dlutsyk, upvoted! 👍 |
Beta Was this translation helpful? Give feedback.
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Background
I've been building a multi-brand application on RR7 and I kept running into the same wall: I have a base app that should be shared across N tenants, with each tenant overriding a few routes, components, and data sources. Today my options are all bad:
routes.tsand the components into each tenant repo, then watch them drift over the next six months.routes.tsdoesn't compose. Each tenant ends up redefining the route tree by hand and re-importing every shared module.routes()– write JS to glob a shared package and merge route trees, then fight type generation, HMR, and route-file resolution to make it ergonomic.I went with (3) and ended up needing to hook into framework internals (route-file resolver, typegen, dev watcher) to make it work. That work is the basis of this proposal.
Why this matters (it's not just multi-brand)
I want to be upfront: I came to this from a multi-brand use case, but layered composition is a general architecture primitive, not a multi-brand feature. It's already proven in the wider ecosystem – every framework that has matured past the "single-app" phase has shipped some version of it:
In every one of these, the value isn't "swap brands" – it's that the framework lets a real-world app be composed from cleanly-separated pieces, each owned by a different team or package, each replaceable independently.
A real architecture I'm shipping today demonstrates this – a single component that spans four distinct packages, each owning one concern:
None of those packages know about the others. The host app just declares the stack:
…and the same component is composed end-to-end. Swap the theme package to reskin. Swap the data adapter to migrate to a different content source without touching UI. Drop a
feature-flagslayer between the abstract and the theme to A/B without touching either side.That's the shape of architecture this unlocks. Concrete examples it makes ergonomic in RR7:
The point is: the framework should provide the composition primitive, not require every team to invent their own. Other frameworks already concluded the same.
API Proposal
Two new experimental config fields. Both reuse existing
routes.tsandreact-router.config.tssurfaces – no new runtime, no new manifest.Bare list
Each layer is a directory that may contain:
react-router.config.{ts,js}– merged below the app config (app always wins)app/routes.{ts,js}– composed additively under the app's root routeString entries and
{ source }entries are functionally identical. The object form exists so future per-entry options can be added without breaking the array shape.Named variants
For apps that need to swap stacks at build time (multi-brand, dev-vs-prod data adapters, theme A/B):
VITE_LAYER=demo npm run buildselects thedemostack. Empty/unset env var falls back todefaultVariant. The same shape can also live in a siblinglayers.config.jsonfor projects that prefer variants out of the TS file.Import aliases (inside any layer's source tree)
Override semantics: any cross-layer file resolution that lands inside a registered layer's directory is shadow-eligible – the highest layer in the stack wins. Route modules and third-party packages are excluded.
Build-time / runtime introspection
How it looks on disk
Here's the directory layout from the consumer demo app I'm running locally — a real app composed from one host plus four layers, with overrides, layer-owned routes, and per-layer tsconfigs all visible:
A few things worth pointing out from this layout:
app/routes.tsandapp/<route>.tsxinside a layer are just the application's structure repeated. There's no new convention to learn — if you can author an RR7 app, you can author a layer.baseadds/about,/contact,/__layers;brandadds/brand;demoadds/fixtures. The host's ownroutes.tsonly needs to declare what's unique to the host (here, the index route). Duplicate IDs error at config-load with a layer-aware diagnostic.base/components/Header.tsx,brand/components/Header.tsx,demo/components/Header.tsx, andminicart/components/Header.tsxall exist. The resolver walks top-down: under theproductionvariant,@/components/Headerresolves tominicart's; underdemo, it resolves tominicart's butbrand/components/Headercan still reach@parent/components/Header(which isbase's).tsconfig.jsonis generated. Eachlayers/<name>/tsconfig.jsonis written by typegen with a"//"marker and gitignored. The host's roottsconfig.jsonextends the typegen-managed.react-router/types/+layer-tsconfig.jsonfragment.@scope/pkgentries identically (require.resolve+ realpath).Status
Implemented end-to-end against
main:react-router-dev(config, layer resolver, typegen, watcher) – passingWhat is your thoughts here? Happy to open a draft PR.
Beta Was this translation helpful? Give feedback.
All reactions