We run a hotel management SaaS (SyncStays) with a dashboard made up of 21 independently-built React apps — booking modal, billing, housekeeping, POS, reports, maintenance, staff access, and so on — all mounting into one shared page.
The "correct" way to do this, everyone will tell you, is Module Federation. So we built it. End to end. A real host + 21 real remotes, all working. And then we deliberately reverted it.
Here's why, and what we did instead.
What went wrong with Module Federation
File explosion. Vite's federation plugin generates roughly 16 small wiring files per remote — manifest, remoteEntry, per-shared-dependency loadShare/commonjs-proxy chunks. Across 21 remotes, that's ~371 files generated just to support one dashboard page load. Not 371 lines. 371 files.
The remoteEntry gotcha. remoteEntry.js's real ESM output needs the host's remotes config to point at the remote's mf-manifest.json — not the raw remoteEntry.js URL. Point at the wrong one and the runtime loads it as a classic script, and you get a delightful Cannot use import statement outside a module with no obvious hint why.
Request count. The sheer number of requests needed to resolve shared-scope negotiation across 21 remotes was straining local dev badly enough that it looked like rate-limiting. On any real HTTP/1.1 origin, this gets worse, not better.
None of this was a skill issue we could route around. It's inherent to how @module-federation/vite implements shared-scope negotiation across that many independently-deployed remotes.
What we did instead
The actual goal was never "use Module Federation" — it was:
- One shared React instance across all 21 apps (no duplication, no version mismatch)
- Per-tab lazy loading (don't fetch an app's bundle until the user navigates to it)
You can get both of those without any federation runtime at all.
One shared React, the pre-bundler way
- We vendor React 18's official UMD production builds straight from
node_modules/react/umd/andreact-dom/umd/— unmodified. Two plain<script>tags on every page, loaded before anything else, settingwindow.React/window.ReactDOM. This is exactly how people put React on a page before bundlers existed. - We hand-write two tiny ESM shim files that just re-export off those globals:
js
export const useState = window.React.useState;
export const useEffect = window.React.useEffect;
- Every app's genuine import { useState } from 'react' gets resolved to these shim files via an import map in each page's <head>. Since every app resolves the bare specifier to the same shim URL, and the shim reads off the one window.React, there's exactly one React instance on the page. No runtime negotiation needed — the browser's native module resolution does the work.
Each app's Vite config marks React as external and forces the classic JSX runtime: react({ jsxRuntime: 'classic' }).
This matters specifically because the automatic runtime compiles JSX into calls against react/jsx-runtime, which has no UMD/global equivalent. Classic runtime compiles <div/> to a bare React.createElement(...) call — esbuild leaves that as an unqualified global reference with no import statement at all, which resolves fine at runtime via window.React.
Lazy loading, via one small host script
Instead of a federation runtime, we have one extra Vite project — host — that builds to a single script every page loads. It's plain vanilla JS with one job: decide which of the 21 app bundles to import(), and when.
- Eager apps: mounted immediately if their container div exists — always-visible dashboard content, modals that need to be ready before a first click, and standalone single-purpose pages.
- Lazy apps: dashboard nav tabs (maintenance, expenses, reports, settings, etc.) — only import()-ed the first time the user actually navigates there. Each of those apps fetches its own data in a mount-time useEffect, so visiting a tab is what triggers its data fetch — not loading the dashboard shell.
One subtlety that bit us early: the container-existence check has to gate the import() call itself, not just the render. Some apps read page-specific globals (window.posAuth.adminUid, say) at module top level, because historically that bundle was only ever script-tagged on one specific page. If the host imports a module it shouldn't, that top-level code runs regardless of whether you ever call mount().
The result
Same practical outcome as Module Federation — one shared React instance, per-tab lazy loading, 21 independently buildable/deployable apps — with none of the wiring-file explosion, none of the manifest-vs-remoteEntry footgun, and a request count that doesn't scare a plain HTTP/1.1 origin.
Sometimes the fancy tool is solving a harder version of your problem than you actually have. Worth checking what you actually need before reaching for the tool built for the general case.
---
We build SyncStays, a hotel management platform (bookings, channel manager, POS, housekeeping) for independent hotels. This is one of the real architecture calls behind it — happy to go deeper on any part in the comments.
Top comments (0)