Status: implemented (tracking issue #154). This document explains how the frontend is split into
independently compiled and deployed applications using Vite Module
Federation, and — the part that's easy
to silently get wrong — how WalletContext and the React Query cache are shared as true runtime
singletons across the module boundary rather than duplicated per bundle.
As Pactum grows to include governance, staking, and identity modules, a single monolithic Vite build becomes a shared deployment and performance bottleneck: every team touching any feature rebuilds and redeploys the whole app, and the whole app's JS ships to every visitor regardless of which page they're on. Module Federation lets each feature area be its own Vite app, built and deployed on its own schedule, and stitched together in the browser at runtime by the host.
frontend/ # host / container application
frontend-dashboard-remote/ # remote: exposes ReputationDashboard
frontend-wizard-remote/ # remote: exposes CreateCommitmentWizard
Each is a fully independent Vite project with its own package.json, dependencies, dev server,
and build output — genuinely independently compiled and deployed, not just independently
organized source folders that still ship in one bundle.
frontend/(host) owns the app shell (landing page, docs, sidebar/navigation — seesrc/App.tsx), and dynamically loads the two remotes' components into the pages that need them. It also owns and exposes the two things every remote needs to share:WalletContext(src/contexts/WalletContext.tsx) and theQueryClientinstance (src/lib/queryClient.ts).frontend-dashboard-remote/exposesReputationDashboard(moved fromfrontend/src/components/, unchanged logic) asdashboard/ReputationDashboard.frontend-wizard-remote/exposesCreateCommitmentWizard(moved the same way) aswizard/CreateCommitmentWizard.
frontend/src/App.tsx loads both remotes with ordinary React.lazy + Suspense, exactly as it
would for local code-splitting — Module Federation's Vite plugin makes import('dashboard/...')
resolve to a runtime fetch of the remote's remoteEntry.js instead of a local chunk:
const ReputationDashboard = lazy(() => import('dashboard/ReputationDashboard'));
const CreateCommitmentWizard = lazy(() => import('wizard/CreateCommitmentWizard'));Each usage site is wrapped in RemoteErrorBoundary (src/components/RemoteErrorBoundary.tsx), so
a remote that's down, still deploying, or failing to load degrades to a retryable inline warning
instead of white-screening the rest of the host (exercised by the last test in
tests/e2e/module-federation.spec.ts).
This is the part of the setup most likely to be gotten subtly wrong, so it's worth being explicit about which mechanism is used for what, and why:
react, react-dom, and @tanstack/react-query are declared shared: { singleton: true } in
every package's vite.config.ts (host and both remotes). This tells Module Federation's
runtime: don't let each bundle load its own copy of this package — negotiate a single shared copy
across the whole federation at runtime. This matters for more than bundle size: React's hooks and
@tanstack/react-query's useQuery/useQueryClient all read off a React Context created by
their respective package. If a remote bundled its own copy instead of sharing, it would create a
second, un-Provided context — useContext there would silently see the default value (or throw)
instead of the value the host's Provider actually supplies, even though everything otherwise looks
correctly wired.
WalletContext and queryClient are not npm packages — they're this app's own code, so they
can't be deduplicated by package name. Instead, the host exposes them (vite.config.ts) and
each remote declares the host as a remotes dependency and imports them by federated specifier:
// frontend-dashboard-remote/src/ReputationDashboard.tsx
import { useWallet } from 'host/WalletContext';This is a genuinely different module resolution path than a relative import — host/WalletContext
resolves, at runtime, to the one WalletContext.tsx module instance the host's
<WalletProvider> already rendered, not a separately bundled copy. Sharing the queryClient
instance this way is necessary even with @tanstack/react-query itself deduplicated via
shared: deduplicating the package doesn't stop two apps from each calling new QueryClient()
independently and getting two different caches. Both mechanisms are needed together.
TypeScript can't see through host/WalletContext or dashboard/ReputationDashboard statically
(they don't exist as real files from its perspective), so each package hand-maintains a narrow
src/remotes.d.ts ambient declaration for exactly the exports it imports, instead of relying on
Module Federation's optional automatic type-generation (dts: false in every vite.config.ts —
we chose the explicit, narrower alternative so a real drift between an exposed module's actual API
and what a consumer assumes shows up as a type error at the declaration site).
frontend/tests/e2e/module-federation.spec.ts runs the host and both remotes together in a real
browser and proves singleton sharing by referential identity, not behavior:
WalletContext.tsxgenerates a randomcontextModuleIdexactly once, when the module is first evaluated, and includes it in the context value. The host'sWalletProviderrecords its id onwindowin dev; both remotes record the id they observe viauseWallet()the same way. The test asserts all three are the same id — if either remote had bundled its own copy ofWalletContext.tsxinstead of consuming the host's exposed one, this would fail (eitheruseWallet()throws with no Provider in that copy's tree, or, if it somehow rendered, the id simply wouldn't match).- Similarly, the host's
queryClient.tsrecords itsQueryClientinstance onwindow; the dashboard remote reads its client viauseQueryClient()— React Query's own Context hook, not the directhost/queryClientimport used elsewhere — and records what it saw. The test asserts===identity, which proves both that the client instance is shared and that@tanstack/react-query'sshared: { singleton: true }config is actually deduplicating the package (a duplicated copy would read a different Context entirely).
These window.__PACTUM_* markers are only ever set behind import.meta.env.DEV, so none of this
ships in production builds.
Each package needs its own npm install. Then, three terminals:
cd frontend-dashboard-remote && npm run dev # http://localhost:5174
cd frontend-wizard-remote && npm run dev # http://localhost:5175
cd frontend && npm run dev # http://localhost:5173 — open this oneThe host's dev server must resolve both remotes' remoteEntry.js to render the Reputation
Lookup and Create Commitment pages; if a remote isn't running, the host still loads and the
affected page shows the RemoteErrorBoundary fallback instead of failing entirely.
npm run test:e2e in frontend/ starts all three dev servers automatically (see
playwright.config.ts's array webServer config) and runs the full Playwright suite, including
module-federation.spec.ts.
npm run build in each package produces an independent, deployable dist/. The host's remote
entry URLs are environment-configured, not hardcoded (frontend/vite.config.ts reads
VITE_DASHBOARD_REMOTE_URL / VITE_WIZARD_REMOTE_URL, falling back to the local dev ports — see
frontend/.env.example), so a remote can be redeployed to a new URL — a new version, a CDN
migration, a canary — without rebuilding or redeploying the host.
The issue that motivated this work names governance, staking, and identity as upcoming modules.
Each should follow the same shape as frontend-dashboard-remote/: its own package.json and
vite.config.ts (exposes its component(s), remotes: { host }, and the same shared
singleton block for react/react-dom/@tanstack/react-query), consume WalletContext and
queryClient from host/... rather than reimplementing or reinstantiating them, and be registered
in the host's vite.config.ts remotes map and loaded via React.lazy + RemoteErrorBoundary in
App.tsx.