8 min readCase study

Building a microfrontend platform for data products

Federation type stubs, Auth0 CTE for downscoped tokens, and CI merge blockers for cache headers, CSP, and remote export drift.

  • Architecture
  • Micro-frontends
  • Module Federation
  • Auth0
  • Platform

Building a microfrontend platform for data products

#TL;DR

LayerChoiceWhy
ShellRspack + Module FederationIndependent deploys per team; shared runtime without monolith repo
EdgeFastify BFFsToken exchange, aggregation, cache policy at platform boundary
ContractsShared MFE type stubsShell compiles against stable remote exposes; drift fails CI
IdentityAuth0 Organizations + Custom Token Exchange (RFC 8693)Org-scoped access + least-privilege tokens per remote
QualityCI on Cache-Control, CSP, stub compliancePlatform invariants enforced at merge, not in prod console

After moving a hot API from 21s to ~250ms, the next bottleneck was frontend coordination: N product UIs, one authenticated session, zero cross-team deploy coupling. I shipped a platform shell — host, federated remotes, BFF seam — so teams could deploy independently without sharing a monolith repo.


#Problem framing

The constraint set was familiar: one login and nav chrome, org-scoped entitlements, and remote deploy ≠ shell deploy without paying the iframe tax (shared React, shared tokens).

Rejected:

  • Monolith — deploy coupling scales with team count
  • Iframe mosaic — duplicated auth, poor UX, no shared component tree

Target invariant: shellVersion compatible with remoteEntry@semver, verified in CI before merge.


#Architecture

Platform shell (host)

Rspack · Module Federation · Auth0 session

Remote A

React MFE · admin domain

Remote B

React MFE · read-heavy domain

Fastify BFF

CTE · aggregate · Cache-Control

OLTP API

.NET · keyed reads/writes

Analytics API

column store

Redis

shared cache handler

The shell owns orchestration only — layout, nav, session bootstrap, remote loader. Remotes own domain UX. BFFs exist where platform policy must run before traffic hits domain APIs.


#Federation manifest: shared singletons

The failure mode that hurts most in prod is two Reacts — hooks throw, context breaks, remotes mount in isolation.

Host module federation config (shape):

TypeScript
new ModuleFederationPlugin({
  name: "shell",
  remotes: {
    remoteA: "remoteA@[remoteAUrl]/remoteEntry.js",
    remoteB: "remoteB@[remoteBUrl]/remoteEntry.js",
  },
  shared: {
    react: { singleton: true, requiredVersion: "^19.0.0", eager: true },
    "react-dom": { singleton: true, requiredVersion: "^19.0.0", eager: true },
    "react-router-dom": { singleton: true, requiredVersion: "^6.0.0" },
  },
});

singleton: true is non-negotiable for React. eager: true on the shell avoids async shared-module races during first remote load.


#Type stubs: federation as an API contract

The shell never imports remote source. It imports a stub module checked into a shared package:

TypeScript
// @platform/mfe-contracts — consumed by shell at compile time
export interface RemoteAExpose {
  mount(el: HTMLElement, ctx: PlatformContext): () => void;
}
 
export type PlatformContext = {
  orgId: string;
  accessToken: string; // audience scoped to remote A
  apiBaseUrl: string;
};

CI runs tsc --noEmit against stubs + remote's declared exposes. If a remote removes or renames an export, the pipeline fails before a user loads a blank panel.

Failure modeDetection
Export renameStub compile failure
React duplicationRuntime warning + federation shared config review
Stale remote chunkCache-Control lint on remoteEntry.js + hashed asset names
CSP blockShell CSP diff in CI

#Fastify BFF: policy, not pass-through

BFF routes exist when at least one of these is true:

  1. Token must be downscoped before reaching a remote or API
  2. N backend calls should collapse to one browser round-trip
  3. Cache-Control must be uniform across remotes (multi-pod shell + Redis cache handler)
TypeScript
app.get("/api/platform/session", async (req, reply) => {
  const claims = await verifyJwt(req.headers.authorization);
  reply.header("Cache-Control", "private, no-store");
 
  return {
    sub: claims.sub,
    orgId: claims.org_id,
    entitlements: await resolveEntitlements(claims.org_id),
  };
});

BFFs that only proxy JSON without adding policy get deleted — they add latency and another failure domain.


#Auth0 Organizations + Custom Token Exchange

Enterprise tenants need org-scoped authorization, not just authentication.

#Organizations (onboarding)

  • Invite → accept → org_id claim on identity
  • Shell reads org context once at bootstrap; remotes receive PlatformContext
  • BFF rejects requests where resource orgId !== token.org_id

#Custom Token Exchange (RFC 8693)

Problem with a single fat access token: every remote sees every scope. Problem with per-remote OAuth: N login flows.

CTE flow:

  1. User completes Auth0 login → subject token (sub, org_id, base scopes)
  2. Shell/BFF POSTs to Auth0 /oauth/token with grant_type=urn:ietf:params:oauth:grant-type:token-exchange
  3. Auth0 returns downscoped access token — narrowed audience, scope, shorter exp
  4. Remote passes that token to its API; API validates aud + org_id independently

Request shape (illustrative):

http
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
 
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<user_access_token>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&audience=https://api.internal/remote-a
&scope=read:records write:records

Response carries a token Remote A's API trusts — Remote B never sees it.

Three flows implemented:

FlowToken purpose
Remote bootstrapBrowser gets audience-scoped token for remote API
BFF → domain APIBFF exchanges user context for service token; secrets stay server-side
Org invite acceptanceEntitlements materialized before any remoteEntry loads

#Remote B: why federation was worth the overhead

One remote is read-heavy with admin/write split APIs — dual OpenAPI surfaces, generated clients (Orval) for each. Backend moved from document store to column-oriented OLAP when filter/join patterns outgrew document indexes.

That remote deploys independently: shell bump does not require remote bump if stub contract unchanged. Federation ROI is positive only when release independence is real — if teams always ship together, use a monorepo.


#CI merge blockers

Platform maturity = failing builds, not wiki pages:

  1. Stub complianceexposes match @platform/mfe-contracts
  2. Cache-Control on remoteEntry.jsno-cache or short max-age + immutable hashed chunks
  3. CSP diff — no new script-src without shell allowlist update

Example CSP invariant the shell enforces:

http
Content-Security-Policy: script-src 'self' https://cdn.internal;
                         connect-src 'self' https://api.internal;

Remotes cannot merge if their asset host isn't allowlisted — prevents prod-only script blocks.

Success metric: zero silent stub breaks during incremental remote rollouts — not raw deploy count.


#What we skipped

  • Federation for code that could ship as a package inside one remote
  • Global client store across remotes (URL + shell context only)
  • Runtime federation without stub compilation in CI
  • BFF proxy on every CRUD route

#Open problems

  1. Remote chunk observability — RUM for remoteEntry load failures and export mismatches
  2. Org-scoped canaries — route subset of tenants to remote@canary before full promotion
  3. Federated design system remote — stricter semver than product remotes

#Auth0 token exchange at the shell boundary

The host exchanges the user’s session for a downscoped API token before remotes mount. Remotes never see the full Auth0 access token — they receive a CTE token scoped to the data product’s audience. When a remote’s bundle drifted on @auth0/auth0-spa-js, the symptom was silent 401s on BFF calls while the shell still looked logged in — fixed by pinning shared auth packages in the host’s shared federation config.


#Stub compilation in CI

Each remote builds against compiled stubs of sibling remotes in CI, not live remoteEntry.js fetches. A missing export fails the build with a type error instead of a white screen in production when team B ships before team A updates the contract.


#Closing thought

Latency work optimizes milliseconds on the hot path. Platform work optimizes contract safety across team boundaries — federation stubs, Auth0 CTE for token shape, CI for cache/CSP invariants. Hosts pay the tax in CI and runtime; remotes pay it in semver. Both sides must treat shared dependencies as a contract, not a suggestion.


#Reader field guide

When this pattern applies: Two or more UI teams share one authenticated product surface; releases must decouple; you can invest in CI contracts (stubs, CSP, cache headers) and a platform BFF for token policy. Skip federation if teams always ship together — a monorepo with packages is cheaper.

Operational checklist

  • Pin React/react-dom as federation singletons (eager: true on the host) before the first remote loads.
  • Check in compile-time stubs for every remote exposes; fail CI on rename/remove.
  • Add a BFF route only when you need token downscope, aggregation, or uniform Cache-Control — delete pass-through proxies.
  • Wire Auth0 Organizations + CTE (RFC 8693) so each remote gets an audience-scoped token, not the shell’s full access token.
  • Lint remoteEntry.js cache policy and CSP allowlists on every remote merge — prod script blocks are expensive.
  • Treat shared auth and router versions in the host shared map as platform semver, not per-team preference.

Shell vs integration style

SituationMonolith SPAIframe mosaicModule Federation + BFF
One team, one release trainBest fitOverkillOverkill
N teams, shared session chromeDeploy couplingWeak UX, duplicate authBest fit
Strict third-party script isolationOKStrong isolationNeeds CSP discipline
Runtime contract enforcementTypes in-repoPostMessage hacksStubs + CI

#On this site

PostWhy
Cutting a data API from 21s to ~250msPrerequisite — why the BFF had to exist on a fast OLTP plane
Bridging AMQPS and JMS for real-time eventsAsync domain events when the UI does not need to wait

#References (curated)

Federation docs explain the loader graph; Auth0’s token-exchange page is what I re-read when remotes need a narrower audience than the shell.

ReferenceNotes
Module Federation conceptsShared scope, singletons, and why the host owns React versions.
Auth0: Custom Token Exchange (RFC 8693)Downscoping tokens per remote without handing every team the shell JWT.
Rspack Module Federation pluginBuild-time wiring we used alongside compile-time stubs in CI.