Micro-frontends
Independent apps behind one gateway on one origin — when the shape earns its cost, and the single property that makes it work.
Verified 2026-08-14 · Observed on a Bun workspace monorepo running 15 micro-frontends behind a gateway, plus one SSR hub.
Default to one application. Splitting a frontend is a structural decision with a permanent cost, and feature structure already gives you boundaries inside a single app — enforced by a test, at no runtime price.
Split only when the parts have different reasons to change and different people changing them. Below that threshold you are paying for a gateway to solve a problem a folder already solved.
The shape
one public origin
│
┌──────▼───────┐
│ gateway │ reverse proxy, owns the origin
└──┬────┬───┬──┘
/billing/ ───┘ │ └─── /* (everything unmatched)
... │
┌───────────────┐ ┌───────────┐ ┌─────▼─────────────────┐
│ billing app │ │ … app N │ │ hub — SSR, database, │
│ base:/billing│ │ │ │ auth, session issuer │
└───────────────┘ └───────────┘ └───────────────────────┘
│
shared internal workspace packages- The gateway owns the public origin and routes each path prefix to its own app. Everything unmatched falls through to the hub, so the hub needs no registration when an app is added.
- One app is the hub — server rendering, database access, and the authority on auth. The rest are lightweight: they carry a base path and hold no infrastructure.
- Shared code travels as internal workspace packages in the same repository, not as published versions. A published package between two apps in one repo buys a release cycle you do not want and a version skew you cannot see.
Same-origin is the whole mechanism
Every app is served from one origin. That single property is what removes CORS, token plumbing, and a second auth implementation per app — the session cookie simply works because nothing ever crossed an origin.
Give each app its own subdomain and the design collapses: you are now issuing and refreshing tokens, running preflight requests, and reimplementing session handling in every app. That is not the same architecture with a different DNS layout — it is a distributed auth problem you chose to create.
So the gateway is not a convenience. It is the thing that holds the invariant, and any change that puts an app on a different origin is a change to the architecture, not to configuration.
What it costs
Name these before choosing the shape — they do not go away, and none of them is visible in a demo:
| Cost | What it looks like in practice |
|---|---|
| A deployment unit per app | Build, release and rollback multiply. The gateway becomes a release dependency for everyone. |
| A shared-package release surface | A change in a workspace package can break an app whose team did not make the change. |
| Duplicate runtime weight | Each app ships its own framework runtime. The second load is cheap only if the routing is right. |
| A single point of failure | The gateway is on the request path for every app, including the hub. |
| Cross-app navigation is a page load | Unless you accept the complexity of client-side routing across app boundaries, which mostly defeats the split. |
Rules that keep it honest
- The hub owns auth, and no app re-implements it. An app reads the session; it never issues one.
- An app never calls another app. Shared behaviour moves into a workspace package, or into the hub. App-to-app calls turn a routing table into a dependency graph nobody drew.
- A path prefix belongs to exactly one app, and the mapping lives in one place. Two apps answering under one prefix is the same failure as two documents owning one rule.
- The gateway proxies WebSocket upgrades so hot reload keeps working across every app in development. A split that makes local development worse will be worked around.
- The dependency rule still applies inside each app. A micro-frontend is not an excuse for a layered tree — see feature structure.