Part 1 covered why this platform uses Module Federation instead of Next.js Multi-Zones or single-spa. This part covers the platform's design goals, its topology, what the Host shell actually owns, and the three shared microfrontends every domain team builds against — and, at the end, how the Host actually wires all three together.
Design goals, stated as constraints
Four goals shape every decision in this series, not just the composition strategy from Part 1:
- Each domain team deploys independently, without redeploying the Host.
- One design system, one state/auth source of truth, one utilities layer — no duplication across MFEs.
- A new domain team can scaffold, build, and deploy an authenticated page in under an hour.
- The whole platform is config-driven enough to be forked and re-branded for a different business unit or organization without touching core code.
Goal 4 is the one easy to underrate reading this series linearly — it's the subject of the last part. Goals 1–3 are what the rest of this part is about.
High-level topology
The Host dynamically loads three shared platform microfrontends as federated singletons, which every domain microfrontend consumes. The Host resolves routes against the manifest at runtime, and a domain's bundle is fetched from the CDN only if the route is authorized:
A few things worth reading directly off that diagram. The platform MFEs sit in one shared singleton scope — one instance, not one per domain MFE. The CDN is only ever contacted after the manifest has already said a route is authorized. Components and Store each reach the Host two different ways at once — solid line, dashed line — which the next section explains; Utilities only ever takes the dashed one. And below the domain layer, a dashed "Backend / BFF" box marks a boundary on purpose: this platform has an opinion about how a page gets found and mounted, and deliberately no opinion at all about where that page gets its data from — that line is for each domain team to draw themselves.
The Host: what it owns, and what it deliberately doesn't
Responsibilities:
- App shell — Header, Side Navigation, Footer, layout regions
- Route registry that resolves domain MFEs dynamically from a manifest, not hardcoded at build time — this is what makes independent deploys possible
- Auth bootstrap — acquires the session once, hands it to the Store MFE's
AuthProvider - Global error boundary + fallback UI per mounted remote, so one MFE crashing doesn't take the shell down with it
- Theme/design-token injection
What it explicitly doesn't own: any domain's business logic, any domain's routes beyond the catch-all that hands off to the manifest, and — per Part 1 — any compiled-in knowledge of which domains exist.
// apps/host/webpack.common.js
module.exports = createModuleFederationConfig({
federation: {
name: 'host',
remotes: {}, // resolved at runtime — see manifest-loader.ts below
shared: {
react: { singleton: true, requiredVersion: '^18.2.0 || ^19.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0 || ^19.0.0' },
'@org/store': { singleton: true, requiredVersion: `^${storePkg.version}` },
'@org/components': { singleton: true, requiredVersion: `^${componentsPkg.version}` },
},
},
});
And the route resolution it hands off to:
// packages/runtime-module-federation/src/manifest-loader.ts
export function resolveRoute(manifest: ManifestEntry[], pathname: string): ManifestEntry | undefined {
return manifest.find((entry) => pathname === entry.route || pathname.startsWith(`${entry.route}/`));
}
The three platform microfrontends
Components MFE — the design system
Exposes the shared component library — buttons, form primitives, modals, header/side-nav chrome, a Forbidden view, and so on — built on design tokens as the actual contract (colors, spacing, typography), so visual consistency survives independent deploys rather than drifting per team.
The mechanism that makes "one design system" true rather than aspirational is dual-publishing: a real tsc build producing an npm-installable package, and a separate webpack build producing a remoteEntry.js, from the same source:
// apps/components-mfe/webpack.common.cjs
module.exports = createModuleFederationConfig({
entry: './src/dev-bootstrap.tsx',
outputDir: 'dist-remote',
federation: {
name: 'components',
filename: 'remoteEntry.js',
exposes: { './components': './src/index.ts' },
shared: {
react: { singleton: true, requiredVersion: '^18.2.0 || ^19.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0 || ^19.0.0' },
'@org/components': { singleton: true, requiredVersion: `^${pkg.version}` },
},
},
});
// apps/components-mfe/package.json (trimmed)
{
"name": "@org/components",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json && node scripts/copy-css.mjs && webpack build --config webpack.prod.cjs"
}
}
tsc gives compile-time type safety and tree-shaking for anyone importing @org/components as a normal npm dependency; the webpack build gives runtime singleton sharing for anyone consuming it as a Module Federation remote. Same source, two consumers, and — because it's federated as a singleton — every domain MFE gets the exact same rendered instance, not N slightly-drifted copies.
Store MFE — everything that has to be one shared instance
This is the platform's other non-negotiable singleton. Its public surface, StoreContract, is one interface covering everything that has to be shared across every mounted page: the login session, state shared for the current tab, state that survives a reload, feature flags, analytics, local-storage-backed state, and logging:
// packages/types/src/store.ts
export interface StoreContract {
useAuth(): AuthState;
useGlobalState<T>(key: string): [T, (value: T) => void];
useGlobalStatePersist<T>(key: string): [T, (value: T) => void];
useFeatureFlag(flagKey: string): boolean;
useAnalytics(): AnalyticsClient;
useLocalStorage<T>(key: string): [T, (value: T) => void];
useLogger(): Logger;
}
Seven hooks, one underlying mechanism: each is backed by exactly one React context, created once, that every consumer across the whole platform reads and writes through. useGlobalState is a good one to look at up close, because it's small enough to show that whole mechanism in one place:
// apps/store-mfe/src/state/GlobalStateContext.tsx
const GlobalStateContext = createContext<GlobalStateContextValue | null>(null);
/**
* One in-memory map behind one context — every useGlobalState() consumer
* across the whole federated singleton reads/writes the same object.
*/
export function GlobalStateProvider({ children }: { children: ReactNode }) {
const [values, setValues] = useState<StateMap>({});
const setValue = useCallback((key: string, value: unknown) => {
setValues((prev) => ({ ...prev, [key]: value }));
}, []);
const value = useMemo(() => ({ values, setValue }), [values, setValue]);
return <GlobalStateContext.Provider value={value}>{children}</GlobalStateContext.Provider>;
}
Every other hook on StoreContract follows this same shape — its own context, its own provider, exposed as a hook. What turns seven separate providers into the one thing the Host actually mounts is StoreProvider, which composes all of them into a single tree:
// apps/store-mfe/src/StoreProvider.tsx
export function StoreProvider({ flags = [], children }: { flags?: FeatureFlag[]; children: ReactNode }) {
return (
<AuthProvider>
<GlobalStateProvider>
<GlobalStatePersistProvider>
<FeatureFlagProvider flags={flags}>
<AnalyticsProvider>{children}</AnalyticsProvider>
</FeatureFlagProvider>
</GlobalStatePersistProvider>
</GlobalStateProvider>
</AuthProvider>
);
}
Without singleton: true on @org/store in every consumer's webpack config, none of this holds — each MFE would mount its own copy of StoreProvider, and two pages' useGlobalState('cart') calls would quietly diverge instead of sharing state.
One more thing lives inside that same tree: a shared cache for data fetched from a backend. StoreProvider also mounts exactly one QueryClientProvider, from TanStack Query — same idea as everything else on this page: one instance, not one per domain MFE, so if two different pages both fetch the same resource, the second call reads the first one's cache instead of firing a duplicate request.
// apps/store-mfe/src/StoreProvider.tsx
const queryClient = new QueryClient();
export function StoreProvider({ flags = [], children }: { flags?: FeatureFlag[]; children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<GlobalStateProvider>
{/* ...the rest, unchanged... */}
</GlobalStateProvider>
</AuthProvider>
</QueryClientProvider>
);
}
@org/store re-exports useQuery, useMutation, and useQueryClient directly — a domain team never installs the library itself:
// apps/store-mfe/src/index.ts
export { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
This is exactly the kind of thing that's easy to get subtly wrong the same way singleton context always is: TanStack Query keeps its cache behind its own React context, so @tanstack/react-query has to be declared singleton: true in every consuming app's webpack config too — the same failure mode as @org/store itself, one layer further down. Here it is actually used, in the orders domain MFE:
// demo/orders/src/DomainApp.tsx
import { useAuth, useQuery } from '@org/store';
async function fetchOrders(): Promise<Order[]> {
const res = await fetch('http://localhost:3005/orders');
if (!res.ok) throw new Error(`Failed to load orders: ${res.status}`);
return res.json();
}
export function DomainApp({ basePath }: { basePath: string }) {
const auth = useAuth();
const { data: orders, isLoading, error } = useQuery({ queryKey: ['orders'], queryFn: fetchOrders });
// ...renders a loading state, an error state, or the list
}
http://localhost:3005 is tools/mock-bff — a small dev-only backend, real and running, that this one domain team owns and calls directly. Nothing about that URL is known to Host, Store, or any other domain MFE; it's orders' own business, the same way its UI is.
Utilities MFE — deliberately not federated
Formatters, validators, generators, convertors — pure, side-effect-free functions:
// apps/utilities-mfe/src/index.ts
// Pure, side-effect-free, tree-shakeable — npm package only. Federating this
// one is optional; skipped for v1 until a real duplication problem shows up.
export * from './formatters/index.js';
export * from './validators/index.js';
export * from './generators/index.js';
export * from './convertors/index.js';
Pure functions don't hold state, so there's no correctness reason to dedupe them at runtime — just ship it as a plain npm dependency and skip the Module Federation machinery entirely. This is worth calling out precisely because it cuts against the instinct to federate everything on principle: federate what has to be one instance, and nothing else.
Wiring it together: how the Host actually uses all three
Components, Store, and Utilities read as three separate pieces above — here's what tying them into the Host actually looks like. The Host's entry point wraps its entire app in exactly one StoreProvider, mounted once, nowhere else:
// apps/host/src/bootstrap.tsx
import { createRoot } from 'react-dom/client';
import { StoreProvider } from '@org/store';
import { loadPlatformConfig } from '@org/runtime-module-federation';
import { App } from './App';
async function main(): Promise<void> {
const container = document.getElementById('root')!;
const root = createRoot(container);
const platformConfig = await loadPlatformConfig();
root.render(
<StoreProvider>
<App platformConfig={platformConfig} />
</StoreProvider>,
);
}
void main();
That single <StoreProvider> is what makes useAuth() — or any other StoreContract hook — return the same value no matter which mounted page calls it. Inside App.tsx, the Host then pulls from all three platform MFEs directly:
// apps/host/src/App.tsx
import { useAuth } from '@org/store';
import { formatDate } from '@org/utilities';
import { Header, SideNav, Footer, HeaderActions } from '@org/components';
export function App({ platformConfig }: { platformConfig: PlatformConfig }) {
const auth = useAuth();
// ...
return (
<BrowserRouter>
<Header title={platformConfig.orgName} actions={<HeaderActions isAuthenticated={auth.claims !== null} /* ... */ />} />
<Routes>{/* domain routes resolved from the manifest */}</Routes>
<Footer text={`${platformConfig.orgName} — Enterprise MFE Platform`} />
</BrowserRouter>
);
}
useAuth() reads the session StoreProvider set up above it. Header, SideNav, and Footer are the Components MFE's design system, rendering the shell chrome from the design goals at the top of this part. formatDate, elsewhere in the same file, is a plain function import from Utilities — no provider, no context, just a function call, exactly because Utilities isn't a singleton the way Store and Components are. One file, all three platform MFEs, doing exactly what each was described as doing above.
The next part covers exactly how that session got there in the first place — from a redirect to an identity provider through to the normalized claims useAuth() returns here.
Domain MFEs — the ×N layer
One per business capability, owned by an independent team. A domain MFE registers itself by adding one entry to the manifest — name, remote URL, route, required role — with no PR against the Host repo required, and it consumes Components/Store/Utilities as shared dependencies, never bundling its own copy of React, the design system, or the state layer.
What a domain MFE actually hands back to Host is fixed by one small contract:
// packages/types/src/domain.ts
export interface DomainMFEExport {
routes: RouteDefinition[];
Component: ComponentType<DomainMFEProps>;
}
This is a component reference, not an imperative mount(container)/unmount() pair — and the reason is a property of React itself, not of Module Federation: React context never crosses a root boundary. A domain MFE that called its own createRoot(container).render(...) would start a second, fully independent React tree — and no amount of correctly-shared modules changes the fact that a component in tree B can't see a Provider that only exists in tree A. Module Federation guarantees the module is shared; it says nothing about the tree. So Host renders each domain MFE's component directly inside its own tree instead:
// packages/runtime-module-federation/src/route-guard.tsx
const RemoteComponent = domainExport.Component;
return <RemoteComponent basePath={entry.route} />;
That single element inherits every context provider above it — the StoreProvider from earlier in this part included — because there's only ever one tree. It's also why every domain MFE ships two entry points, not one file with a runtime branch: a standalone-preview entry that calls createRoot() and wraps itself in its own StoreProvider (no Host around to supply one), and a federated entry that exports the bare { routes, Component } object and renders nothing — Host supplies the provider when it mounts it. The same component code runs unmodified in both.
For its own data, a domain MFE calls its own backend directly — orders calling tools/mock-bff above is the pattern, not a special case. Whether that means one shared gateway behind every domain or a separate backend per team is deliberately not this platform's call to make: the topology diagram draws that box dashed, on purpose, and every domain team is free to answer the question differently.
Dependency management: two layers, not one
"Shared dependencies" means two different things here, and the platform handles them differently:
Build-time, via a private package registry (GitHub Packages, Verdaccio, Artifactory) hosting versioned internal packages — @org/components, @org/store, @org/utilities, plus @org/types (shared TypeScript contracts, so a breaking change to StoreContract or ManifestEntry fails at compile time, not in production) and shared tooling config (@org/eslint-config, @org/tsconfig-base).
Run-time, via Module Federation's shared scope — the mechanism from Part 1 that dedupes React, React-DOM, and the platform MFEs across every remote loaded into the Host, so the browser downloads one copy of each regardless of how many domain MFEs are mounted simultaneously.
Locally, a monorepo (this platform uses Turborepo, covered later in this series) lets you run the Host, the platform MFEs, and whichever domain MFE you're actively building together, with incremental builds and remote caching. Module Federation itself doesn't require a monorepo — polyrepo works fine — a monorepo just makes local cross-MFE development meaningfully less painful.
Next: Part 3 — One Login, Every Microfrontend: The Auth Architecture

Top comments (0)