Architect
Standards
ReferenceNormative

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-10-03 · Observed on a Bun workspace monorepo running seventeen product apps behind a gateway, each a TanStack Start server calling its own backend service, plus a shell and an identity app. Excerpts are adapted, names changed.

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  │  │  shell — login,       │
   │  SSR, /billing │  │  SSR      │  │  navigation, /api     │
   └───────┬────────┘  └─────┬─────┘  └───────────────────────┘
           │                 │
   ┌───────▼────────┐  ┌─────▼─────┐        identity app —
   │ billing service│  │ service N │        issues the session
   └────────────────┘  └───────────┘
                  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 shell, so the shell needs no registration when an app is added.
  • Every product app renders on the server and calls its own service. It is a full TanStack Start server: its loaders seed the first screen, and its server functions call that product's backend as the signed-in viewer. It never calls another product's service.
  • The shell and the identity app hold what is shared — login and navigation in the shell, the session in identity. A product app reads the session; it never issues one.
  • 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:

CostWhat it looks like in practice
A deployment unit per appBuild, release and rollback multiply. The gateway becomes a release dependency for everyone.
A shared-package release surfaceA change in a workspace package can break an app whose team did not make the change.
Duplicate runtime weightEach app ships its own framework runtime. The second load is cheap only if the routing is right.
A single point of failureThe gateway is on the request path for every app, including the shell.
Cross-app navigation is a page loadUnless you accept the complexity of client-side routing across app boundaries, which mostly defeats the split.

Rules that keep it honest

  • One app owns auth, and no app re-implements it. Each product app reads the session on the server, in a root route every app shares — no session is a server-side redirect to the shell's login, before anything renders:
shared/web/chrome/src/product-root-route.tsx (excerpt)
export function createProductRootRoute({ appCssHref }: { appCssHref: string }) {
  return createRootRouteWithContext<{ queryClient: QueryClient }>()({
    // This is also where the app is PROTECTED: no session means a server-side
    // redirect to the shell's login page, a document navigation because it
    // belongs to the shell.
    beforeLoad: async ({ context }) => {
      await context.queryClient.fetchQuery(sessionQueryOptions());
      const session = context.queryClient.getQueryData<SessionData>(sessionQueryKey) ?? null;
      if (!session?.user) throw redirect({ href: "/login" });
      await context.queryClient.fetchQuery(brandingQueryOptions());
    },
    head: () => ({ links: [{ rel: "stylesheet", href: appCssHref }] }),
    shellComponent: RootDocument,
  });
}

function RootDocument({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        {/* Theme before first paint, so dark mode never flashes light-then-dark. */}
        <ScriptOnce>{getThemeScript()}</ScriptOnce>
        <HeadContent />
      </head>
      <body>
        <ThemeProvider>
          {children}
          <Toaster />
        </ThemeProvider>
        <Scripts />
      </body>
    </html>
  );
}

Simplified from a production app, where it replaced a copy of the same root in every app. Each app's root is then one line: createProductRootRoute({ appCssHref: appCss }).

When not to: one app keeps its own root route; the shared factory earns its place at the second app.

  • An app never calls another app. Shared behaviour moves into a workspace package. 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.

When not to split

  • One team. The boundary buys coordination you do not need.
  • Shared state across the boundary. If two parts need the same live client state, they are one app and the split will be undone by a shared store.
  • A content-led product. That is a website — see Astro and the line.

On this page