<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: SyncStays</title>
    <description>The latest articles on DEV Community by SyncStays (@syncstays_37e6c2e6e83a3a1).</description>
    <link>https://dev.to/syncstays_37e6c2e6e83a3a1</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4112393%2Fd1d1efc4-5db1-4ba6-a7cc-f6c7ce51db28.png</url>
      <title>DEV Community: SyncStays</title>
      <link>https://dev.to/syncstays_37e6c2e6e83a3a1</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/syncstays_37e6c2e6e83a3a1"/>
    <language>en</language>
    <item>
      <title>We Built a 21-Microfrontend Dashboard with Module Federation, Then Ripped It All Out</title>
      <dc:creator>SyncStays</dc:creator>
      <pubDate>Sun, 06 Sep 2026 13:58:59 +0000</pubDate>
      <link>https://dev.to/syncstays_37e6c2e6e83a3a1/we-built-a-21-microfrontend-dashboard-with-module-federation-then-ripped-it-all-out-3ki8</link>
      <guid>https://dev.to/syncstays_37e6c2e6e83a3a1/we-built-a-21-microfrontend-dashboard-with-module-federation-then-ripped-it-all-out-3ki8</guid>
      <description>&lt;p&gt;We run a hotel management SaaS (&lt;a href="https://www.syncstays.com" rel="noopener noreferrer"&gt;SyncStays&lt;/a&gt;) 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Here's why, and what we did instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong with Module Federation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;File explosion.&lt;/strong&gt; Vite's federation plugin generates roughly 16 small wiring files per remote — manifest, remoteEntry, per-shared-dependency &lt;code&gt;loadShare&lt;/code&gt;/commonjs-proxy chunks. Across 21 remotes, that's ~371 files generated just to support one dashboard page load. Not 371 lines. 371 files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The remoteEntry gotcha.&lt;/strong&gt; &lt;code&gt;remoteEntry.js&lt;/code&gt;'s real ESM output needs the host's &lt;code&gt;remotes&lt;/code&gt; config to point at the remote's &lt;code&gt;mf-manifest.json&lt;/code&gt; — not the raw &lt;code&gt;remoteEntry.js&lt;/code&gt; URL. Point at the wrong one and the runtime loads it as a classic script, and you get a delightful &lt;code&gt;Cannot use import statement outside a module&lt;/code&gt; with no obvious hint why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Request count.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;None of this was a skill issue we could route around. It's inherent to how &lt;code&gt;@module-federation/vite&lt;/code&gt; implements shared-scope negotiation across that many independently-deployed remotes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we did instead
&lt;/h2&gt;

&lt;p&gt;The actual goal was never "use Module Federation" — it was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;One shared React instance across all 21 apps (no duplication, no version mismatch)&lt;/li&gt;
&lt;li&gt;Per-tab lazy loading (don't fetch an app's bundle until the user navigates to it)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can get both of those without any federation runtime at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  One shared React, the pre-bundler way
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;We vendor React 18's official UMD production builds straight from &lt;code&gt;node_modules/react/umd/&lt;/code&gt; and &lt;code&gt;react-dom/umd/&lt;/code&gt; — unmodified. Two plain &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags on every page, loaded before anything else, setting &lt;code&gt;window.React&lt;/code&gt; / &lt;code&gt;window.ReactDOM&lt;/code&gt;. This is exactly how people put React on a page before bundlers existed.&lt;/li&gt;
&lt;li&gt;We hand-write two tiny ESM shim files that just re-export off those globals:&lt;/li&gt;
&lt;/ul&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
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 &amp;lt;head&amp;gt;. 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 &amp;lt;div/&amp;gt; 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.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>architecture</category>
      <category>frontend</category>
      <category>react</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
