Module Federation 2.0
Module Federation 2.0 (MF 2.0) is the runtime layer that lets the UI Shell load independently deployed module bundles without a full rebuild. From the browser's perspective, the shell boots once and modules appear on demand — each module is a live, versioned Vite build served from a CDN.
Host and Remote Architecture
Browser
└─ UI Shell (Host) ← Vite 8 + React 19 + @module-federation/vite
├─ remoteEntry.js ← Module A (independently deployed)
├─ remoteEntry.js ← Module B
└─ remoteEntry.js ← Module N (up to 50 per tenant)
| Role | Description |
|---|---|
| Host | ui-shell — single Vite 8 application. Owns the layout, sidebar, and top-level routing. Never rebuilt when a module deploys. |
| Remote | Each installed module. Exposes one React component via federation.config.js. Deployed independently to a versioned CDN URL. |
The list of active remotes is populated at runtime from the Module
Registry, not hardcoded in the shell build. On login, the shell calls
GET https://api.septemcore.com/v1/modules?status=active and constructs
the federation manifest dynamically.
Singleton Shared Dependencies
Duplicate instances of React, Zustand, or the SDK would cause subtle runtime bugs (split context, multiple store instances). MF 2.0 enforces a single copy of each shared package:
// federation.config.js (in every module)
import { defineConfig } from '@module-federation/vite';
export default defineConfig({
name: 'crm',
exposes: {
'./App': './src/App',
},
shared: {
react: { singleton: true, requiredVersion: '^19.0.0' },
'react-dom': { singleton: true, requiredVersion: '^19.0.0' },
'react-router-dom': { singleton: true, requiredVersion: '^7.0.0' },
zustand: { singleton: true, requiredVersion: '^5.0.0' },
'@platform/sdk-core': { singleton: true },
},
});
If a module's requiredVersion constraint does not match the version
already loaded by the shell, MF 2.0 refuses to load the module and
triggers the Error Boundary. The shell displays a
Module version mismatch — update required notice in the sidebar.
Use the kernel's SDK packages rather than pinning versions of shared dependencies directly. SDK versions are managed centrally for the entire platform.
Dynamic Loading Flow
1. User logs in → shell fetches active module list from Module Registry
2. Shell builds federation manifest (runtime remotes object)
3. User navigates to /crm → React Router triggers lazy import
4. MF 2.0 fetches remoteEntry.js from CDN (Cache-Control: no-cache, ETag)
5. Shared singletons negotiated — React, Zustand, SDK already in host scope
6. Module's App component mounts inside Shell layout slot
7. Module subscribes to Event Bus (kernel.events().subscribe(...))
Lazy loading is scoped to the active route only. The sidebar renders all module icons immediately (names and icons come from the Registry), but JavaScript bundles are not fetched until the user navigates to a module. This means 50 installed modules do not equal 50 network requests at boot.
Module State Machine
pending → installing → active
↘ failed
| State | Description |
|---|---|
pending | Install requested, assets not yet on CDN. |
installing | Registry downloading bundle, uploading to S3, registering routes. |
active | All steps complete. Shell loads the module. Timeout: 120 s (MODULE_INSTALL_TIMEOUT_SEC). |
failed | Any step failed. Admin is notified. Retry via POST /api/v1/modules/:id/retry-install. |
The UI Shell displays a module only when its status is active.
MFE Lifecycle Management
Long-running admin sessions (8+ hours) accumulate memory if modules are never cleaned up. The shell enforces the following lifecycle rules:
| Mechanism | Description |
|---|---|
| Unmount on navigation | When the user navigates away, the shell unmounts the previous module's React tree, triggering all useEffect cleanup functions. |
kernel.lifecycle().onDeactivate() | Modules register cleanup callbacks here. Required for WebSocket connections, timers, and Event Bus subscriptions. |
| LRU eviction | If more than 10 module bundles are loaded concurrently, the least recently used bundle is evicted from memory (the JS module cache). Bundle is re-fetched on next navigation. |
// Inside any module component — mandatory cleanup pattern
import { useEffect } from 'react';
import { kernel } from '@platform/sdk-core';
export function useCrmSubscription() {
useEffect(() => {
const unsub = kernel.events().subscribe(
'crm.contact.created',
handleContactCreated,
);
// Registered here so shell can call it on module deactivation
kernel.lifecycle().onDeactivate(() => unsub());
return () => unsub(); // also handles React StrictMode double-invoke
}, []);
}
Zustand Singleton — Cross-MFE State
Because zustand is declared singleton: true in the federation config,
all modules share the same Zustand instance as the host shell.
This is the only supported mechanism for cross-module state sharing.
// packages/event-bridge/src/store.ts (part of @platform/sdk-core)
import { create } from 'zustand';
interface PlatformStore {
tenantId: string;
userId: string;
activeModuleId: string | null;
setActiveModule: (id: string | null) => void;
}
export const usePlatformStore = create<PlatformStore>((set) => ({
tenantId: '',
userId: '',
activeModuleId: null,
setActiveModule: (id) => set({ activeModuleId: id }),
}));
// In any module — read shared state
import { usePlatformStore } from '@platform/sdk-core';
export function ContactList() {
const tenantId = usePlatformStore((s) => s.tenantId);
// tenantId is always authoritative — injected by shell on login
}
Do not create module-local Zustand stores that duplicate kernel-level
state (tenantId, userId, permissions). Those are owned by the shell
and exposed via @platform/sdk-core. Module-local stores are fine for
ephemeral UI state (selected rows, open drawers).
Cache Invalidation on Module Update
When a new module version is deployed, the Module Registry emits
module.registry.updated. The UI Shell handles this event:
- Invalidates the
localStoragemanifest cache for the updated module. - Adds
Cache-Control: no-cacheon the next fetch ofremoteEntry.js. - Shows a toast notification: "Module {name} has been updated. Refresh to apply."
Updates are applied on the next navigation to the module or when the user clicks the toast. No forced page reload.
Versioned CDN URLs (Immutable Deployments)
https://cdn.septemcore.com/modules/{tenantId}/{moduleId}/{version}/remoteEntry.js
Each version is an immutable CDN deployment. Rolling back a module means
the Registry switches the tenant's entry pointer back to the previous
version URL — no re-upload or rebuild required.
Embed Loader — External Embedding
Modules can expose Web Components for embedding on external sites.
The embed-loader package (packages/embed-loader/) handles this:
<!-- External site includes one script tag -->
<script
src="https://cdn.septemcore.com/embed-loader.js"
data-tenant="tenant_uuid"
data-module="crm"
data-component="ContactWidget"
></script>
<div id="platform-embed-crm-contactwidget"></div>
The embed loader:
- Fetches the module's
entryURL from the Module Registry using thedata-tenantcontext. - Bootstraps a minimal MF 2.0 runtime (no full React shell).
- Mounts the declared component inside the target
<div>. - Scopes all API calls to the tenant from the JWT passed by the
host page via
postMessage.
Embeddable components are declared in the module manifest under
embeds[]:
{
"embeds": [
{
"name": "ContactWidget",
"component": "./src/embeds/ContactWidget",
"description": "Embeddable contact capture form"
}
]
}
federation.config.js — Complete Template
// federation.config.js (in module root — required)
import { defineConfig } from '@module-federation/vite';
export default defineConfig({
name: 'my_module', // snake_case, must match manifest "name" slug
filename: 'remoteEntry.js', // fixed — Registry expects this filename
exposes: {
// Key must match manifest "exposedComponent" field
'./App': './src/App',
},
shared: {
react: { singleton: true, requiredVersion: '^19.0.0' },
'react-dom': { singleton: true, requiredVersion: '^19.0.0' },
'react-router-dom': { singleton: true, requiredVersion: '^7.0.0' },
zustand: { singleton: true, requiredVersion: '^5.0.0' },
'@platform/sdk-core': { singleton: true },
'@platform/sdk-auth': { singleton: true },
'@platform/sdk-data': { singleton: true },
'@platform/sdk-events': { singleton: true },
},
});
Limits
| Parameter | Value | Override |
|---|---|---|
| Max modules per tenant | 50 | REGISTRY_MAX_MODULES_PER_TENANT (Billing plan) |
| Max concurrent loaded bundles | 10 (LRU eviction after) | — |
| Module install timeout | 120 s | MODULE_INSTALL_TIMEOUT_SEC |
| Health check timeout per module | 5 s | MODULE_HEALTH_TIMEOUT_MS |
| Auto-rollback window after update | 60 s | — |
| JS bundle CDN versions kept | Last 5 | S3 Lifecycle Rule |
| Deactivated module bundle retention | 90 days | — |