Backends figured out independent deployment in 2014 with microservices. By 2018, companies had 50 backend services deploying independently, but one monolithic React app that took 45 minutes to build and required 6 teams to coordinate every release.
The frontend monolith became the bottleneck:
2014: "We broke the backend into microservices. Teams deploy independently now."
2018: "Great. But all 6 teams still share one React app.
Team A's broken test blocks Team B's hotfix.
Team C's dependency upgrade breaks Team D's component.
We deploy the frontend once a week because the coordination cost is too high."
Microfrontends apply the same decomposition principle to the frontend. Each team owns a vertical slice: not just the API, but the UI, the routes, the styles, everything from database to pixel. They deploy independently without asking anyone.
Backend decomposition was natural because the boundaries already existed. /api/products hits the product controller, /api/cart hits the cart controller. They were already separate routes handled by separate code. Splitting them into separate services meant each team could scale, deploy, and maintain their piece independently. The cost is inter-service network latency, but in practice that's sub-5ms for co-located services, barely noticeable. And in return, you gain the ability to scale only the services that need it (product search gets 10x more traffic than cart checkout).
Backend: decomposition is natural, each URL was already a separate controller
/api/products -> product-service (scale to 20 replicas during Black Friday)
/api/cart -> cart-service (scale to 5 replicas, it's lighter)
/api/auth -> auth-service (scale to 3 replicas, mostly idle)
Cost: ~2-5ms network latency per inter-service call. Net positive.
The frontend has no such natural boundary. The user sees ONE page. When you look at a product page, the header (auth team), the product grid (product team), and the cart badge (cart team) are all rendered simultaneously in the same DOM, on the same screen, in the same browser tab. There's no "separate URL per team": it's one URL, one page, multiple owners.
Frontend: user sees ONE page, multiple teams' code on the same screen at once
Browser -> ONE page with header (auth-team) + products (product-team) + cart badge (cart-team)
You can't just redirect them to auth.yourcompany.com/login for login, then products.yourcompany.com/catalog for products, then cart.yourcompany.com/checkout for cart. That would be:
So the core challenge is: how do you split ownership across teams while keeping the user experience of a single, seamless application?
This is the problem microfrontends solve. Not "how do we break the frontend into pieces" (that's easy, just make separate apps). The hard part is putting those pieces back together so the user can't tell they came from different teams, different repos, and different deploy pipelines.
That means solving:
The pattern didn't come from a whitepaper. It came from pain:
The common thread: the problem isn't technical, it's organizational. Microfrontends exist because Conway's Law is real. Your system architecture will eventually mirror your team structure, whether you plan for it or not. Better to make that explicit.
| Monolith Problem | MFE Solution |
|---|---|
| One build pipeline. 45-minute CI. Everyone waits. | Each service builds in <60 seconds. Deploy in isolation. |
Shared node_modules. One team upgrades React 18 -> 19, breaks 3 other teams. |
Each service owns its own dependencies. Upgrade on your own schedule. |
| Merge conflicts on shared routes, layouts, state management. | Services only share a manifest contract and EventBus channels. No shared code to conflict on. |
| One team's broken test blocks the entire release. | Teams have their own test suites, their own CI, their own "ship it" button. |
| Can't adopt new tech. "We're a React shop." | Product team uses Svelte. Auth team uses vanilla TS. Shell doesn't care: it calls mount(div). |
| Knowledge silos. Only 2 people understand the router. | Each team owns their complete vertical. No cross-team dependencies for most changes. |
I've known about microfrontends for about a year but never found a good, complete example that showed how to actually implement them properly: how to organize the project, how services discover each other, how state flows, how components are structured. Most tutorials stop at "here's a counter in an iframe."
This repo is my attempt to understand microfrontends by building the same e-commerce app 5 times using different frameworks and integration patterns. Building it multiple ways helps me understand:
The patterns are framework-agnostic. Manifest discovery, EventBus, header widget composition, progressive loading. They work the same regardless of framework. The framework is an implementation detail.
Trade-offs differ by rendering model and integration strategy. Next.js adds SSR, Vite/Svelte are purely CSR, Angular uses webpack-based Module Federation, and the mixed implementation proves services can use entirely different frameworks.
How to implement MFE in each framework. Each implementation teaches different things: Angular's module system, Svelte's compilation model, vanilla TS with zero framework overhead, and how cross-framework composition actually works in practice.
| Implementation | Directory | Shell Port | Integration Pattern | Frameworks |
|---|---|---|---|---|
| Next.js (SSR) | nextjs/ |
:3000 |
Module Federation | React (all services) |
| Vite (CSR) | vite/ |
:5000 |
Module Federation | Vanilla TypeScript (all services) |
| Svelte (CSR) | svelte/ |
:4000 |
Module Federation | Svelte (all services) |
| Angular | angular/ |
:6010 |
Module Federation (webpack) | Angular (all services) |
| Mixed (cross-framework) | mixed/ |
:7000 |
IIFE + mount/unmount registry | Shell: vanilla TS, Auth: React, Products: Svelte, Cart: Vue |
Each implementation is organized as a monorepo with apps/ (deployable services) and packages/ (shared libraries). The shared package is its own isolated package, ready to be extracted into a separate repo when needed.
microfronend/
├── package.json # Root (scripts to launch each implementation)
├── README.md
├── docs/screenshots/ # All screenshots
├── nextjs/ # Same-framework MFE: React + Next.js (SSR)
│ ├── apps/
│ │ ├── shell/ # Shell (Next.js, port 3000)
│ │ ├── auth-service/ # Auth remote (port 3001)
│ │ ├── product-service/ # Product remote (port 3002)
│ │ └── cart-service/ # Cart remote (port 3003)
│ └── packages/
│ ├── shared/ # EventBus, types, auth-store
│ └── storybook/ # Centralized component stories
├── vite/ # Same-framework MFE: Vanilla TypeScript (CSR)
│ ├── apps/
│ │ ├── shell/ # Shell (port 5000)
│ │ ├── auth-service/ # Auth remote (port 5001)
│ │ ├── product-service/ # Product remote (port 5002)
│ │ └── cart-service/ # Cart remote (port 5003)
│ └── packages/
│ ├── shared/
│ └── storybook/
├── svelte/ # Same-framework MFE: Svelte (CSR)
│ ├── apps/
│ │ ├── shell/ # Shell (port 4000)
│ │ ├── auth-service/ # Auth remote (port 4001)
│ │ ├── product-service/ # Product remote (port 4002)
│ │ └── cart-service/ # Cart remote (port 4003)
│ └── packages/
│ ├── shared/
│ └── storybook/
├── angular/ # Same-framework MFE: Angular (webpack Module Federation)
│ ├── apps/
│ │ ├── shell/ # Shell (port 6010)
│ │ ├── auth-service/ # Auth remote (port 6001)
│ │ ├── product-service/ # Product remote (port 6002)
│ │ └── cart-service/ # Cart remote (port 6003)
│ └── packages/
│ └── shared/
└── mixed/ # Cross-framework MFE: React + Svelte + Vue
├── apps/
│ ├── shell/ # Shell: vanilla TS (port 7000)
│ ├── auth-service/ # Auth: React 18 (port 7001)
│ ├── product-service/ # Products: Svelte 4 (port 7002)
│ └── cart-service/ # Cart: Vue 3 (port 7003)
└── packages/
└── shared/ # EventBus, types, MFE registry
Within each service, components follow atomic design by domain:
auth-service/src/components/ # (or src/lib/components/ for Svelte)
├── atoms/ # Smallest reusable pieces
│ ├── Avatar.ts # Renders an initial in a circle
│ ├── Button.ts # Multi-variant button
│ └── FormInput.ts # Label + input pair
├── molecules/ # Composed atoms
│ └── UserCard.ts # Avatar + name + email
└── organisms/ # Full features (what the shell mounts)
├── LoginForm.ts # Composes UserCards into a login flow
└── UserMenu.ts # Avatar + name + logout button
Each domain service owns its own atoms. Auth has Avatar, cart has CartBadge, product has PriceTag. There's no cross-domain shared component library: that would recreate the monolith's coordination bottleneck. If two domains need a similar button, they each build their own.
+------------------------------+
| Browser |
| |
| / ----------> Home Page |
| /products --> Product List |
| /products/3 > Product Detail|
| /cart ------> Cart Page |
| /login -----> Login Form |
+----------+--------------------+
|
+----------v--------------------+
| SHELL (Host App) |
| |
| +-------------------------+ |
| | Header | |
| | [Brand] [MiniCart][User] | |
| | ^ ^ ^ | |
| | | cart-svc auth-svc| |
| +-------------------------+ |
| +--------+ +--------------+ |
| |Sidebar | | Main Content | |
| | | | | |
| | Home | | Dynamically | |
| | Prods +-+ loaded from | |
| | Cart | | remote service| |
| | Login | | based on URL | |
| | | | | |
| |(auto- | | | |
| |built | | | |
| |from | | | |
| |manifests)| | |
| +--------+ +--------------+ |
+------------------------------+
|
+----------------------+----------------------+
| | |
+---------v----------+ +--------v---------+ +---------v----------+
| Auth Service | | Product Service | | Cart Service |
| | | | | |
| - LoginForm | | - ProductCatalog | | - CartPage |
| - UserMenu (widget) | | - ProductDetail | | - MiniCart (widget) |
| - /api/manifest | | - /api/manifest | | - /api/manifest |
| - /api/login | | - /api/products | | - /api/cart |
+---------------------+ +-------------------+ +---------------------+
Services are stateless. No service holds per-user state in process memory. Auth-service validates credentials and returns user data: it has no idea who is currently logged in. In production, the same function becomes JWT signature verification (still stateless, no session lookup). The shell owns the session via cookies (set client-side on auth:login event, read server-side in Next.js via getServerSideProps). Cart data is keyed by userId (backed by a Map in this demo, Redis or a DB in production). Any service can restart or scale without losing user sessions.
Services communicate through event channels, not shared globals. A service only needs to know the channel name and payload shape (cart:add, auth:login) to participate. It subscribes to events through the EventBus and receives updates when state changes. It never reads another service's internal storage directly.
Single source of truth for every concern:
| Concern | Owner | Mechanism |
|---|---|---|
| Auth state | Shell (cookie + EventBus) | Auth-service validates only. Shell sets a cookie on auth:login event, reads it server-side in Next.js (getServerSideProps), and broadcasts via EventBus in all frameworks. |
| Cart state | Cart-service | Only writer. Other services fire cart:add events. Cart-service handles them and emits cart:updated. |
| Navigation | Shell | Services declare routes via manifest. Shell renders the sidebar. No service builds its own nav. |
| Design tokens | Tailwind CSS | bg-[#1a1a2e] means the same thing everywhere. No per-team variables that drift. |
| Event contracts | shared/ package TypeScript types |
Typed event names and payloads. Breaking changes surface at build time. |
What's NOT shared (and shouldn't be):
<SharedButton> that 3 teams fight over.Auth security: window globals and localStorage have the same XSS attack surface: any script on the page can read both. For non-sensitive display data (username, role), this is acceptable. For tokens, it is not. Sensitive tokens (JWTs, session IDs) belong in httpOnly cookies where JavaScript can't touch them.
Login flow (all frameworks converge on this pattern):
1. User submits credentials
2. Auth-service validates (stateless, stores nothing)
3. Shell sets a cookie via document.cookie on auth:login event
- Browser sends the cookie automatically on every request
- In Next.js, getServerSideProps reads this cookie for SSR
4. On page load, shell reads the cookie and broadcasts user profile via EventBus
5. Cart/session data lives in a database, not process memory
Decision: Each remote service exposes a manifest describing its routes and header widgets. The shell discovers them at runtime and builds navigation dynamically. Zero hardcoded routes in the shell.
{
"name": "productService",
"routes": [
{ "path": "/products", "label": "Products", "icon": "📦" }
],
"headerWidgets": [
{ "name": "mini-cart", "exposedModule": "./MiniCart" }
]
}
Why not hardcode routes in the shell?
The naive approach is to have the shell define all routes upfront:
// DON'T DO THIS: shell becomes the bottleneck
const routes = [
{ path: "/products", component: "productService/ProductCatalog" },
{ path: "/cart", component: "cartService/CartPage" },
];
This creates a coupling problem. Every time the product team adds a route, they need the shell team to update the config and redeploy. You've recreated the monolith's merge-conflict problem: the shell becomes the coordination bottleneck.
With manifest discovery, the product team adds a route to their manifest, deploys their service, and the shell picks it up automatically on the next page load. No coordination required.
Trade-off accepted: The shell doesn't know all possible routes at build time. No compile-time route validation or static sitemap generation. For an SPA this is fine. For SEO-critical sites, the Next.js approach can partially address this since getServerSideProps can fetch manifests before rendering.
How it differs by framework:
| Framework | Discovery Mechanism | Why This Way |
|---|---|---|
| Next.js | import("authService/manifest") via Module Federation |
Federation plugin resolves remote modules at runtime via remoteEntry.js. Shell can also server-render page props. |
| Vite | import("authService/manifest") via Module Federation |
Client-side: federation plugin resolves remote modules at runtime via remoteEntry.js |
| Svelte | Same as Vite | Same client-side federation, just with Svelte components |
| Angular | import("authService/LoginForm") via webpack Module Federation |
Angular's webpack config maps remote names to remoteEntry.js URLs |
| Mixed | fetch("http://service:port/manifest.json") + <script> injection |
No federation - shell fetches JSON manifests and injects IIFE bundles directly |
Decision: discoverRemotes() is fire-and-forget. The shell renders immediately with its own layout. Remote manifests load in the background and nav items pop in as each one responds.
Timeline:
0ms Shell layout renders (header + sidebar + main area)
50ms Product manifest loaded -> "Products" appears in sidebar
80ms Cart manifest loaded -> "Cart" appears, MiniCart mounts in header
120ms Auth manifest loaded -> UserMenu mounts in header
5000ms Timeout: anything not loaded yet is silently skipped
Why not await Promise.all(manifests) before rendering?
The shell showed a blank page for 200-300ms while fetching all manifests. The real problem appeared when one service was down: the entire page blocked for 5 seconds waiting for the timeout. Users saw nothing.
The fire-and-forget approach means:
Trade-off accepted: Brief "pop-in" as nav items appear asynchronously. In practice <100ms and barely noticeable.
Decision: Four implementations (Next.js, Vite, Svelte, Angular) use Module Federation for runtime integration. The mixed implementation uses IIFE bundles with a mount/unmount registry instead, because Module Federation requires all services to share the same build toolchain.
Module Federation (Next.js, Vite, Svelte, Angular):
Browser --> Shell
+-- import("authService/UserMenu") <- fetches from remoteEntry.js
+-- import("productService/manifest") <- fetches from remoteEntry.js
+-- import("cartService/CartPage") <- fetches from remoteEntry.js
Each service builds a remoteEntry.js that the shell imports at runtime. Components mount directly in the shell's DOM.
IIFE + Registry (Mixed):
Browser --> Shell
+-- fetch("/manifest.json") <- discover service capabilities
+-- inject <script src="mfe-entry.iife.js"> <- load service bundle
+-- window.__MFE_REGISTRY__["auth"].mount(div) <- mount into container
Each service builds as an IIFE that self-registers on a global registry. The shell fetches manifests, injects scripts, and calls mount/unmount. No Module Federation needed. Any framework that can produce a JS bundle works.
CSR (Vite/Svelte/Angular/Mixed):
SSR + CSR Hybrid (Next.js):
getServerSidePropsnext/dynamic with ssr: false@module-federation/nextjs-mf (NextFederationPlugin) with Pages RouterTrade-off: Module Federation couples you to the build tool. The mixed IIFE approach removes this constraint but adds more JavaScript (each service bundles its own framework runtime).
Decision: Cross-service communication uses typed CustomEvents dispatched on window. No Redux, no Zustand, no shared store.
// Product service: fires event when user clicks "Add to Cart"
EventBus.emit("cart:add", { productId: "1", name: "Headphones", price: 79.99 });
// Cart service: listens and updates its own state
EventBus.on("cart:add", (item) => { addToCart(item); updateMiniCartBadge(); });
// Auth: broadcasts login state changes
EventBus.emit("auth:login", { id: "1", name: "Alice" });
Why not a shared state library?
Shared state libraries (Redux, Zustand, MobX) assume a single build: all consumers import the same store instance. In microfrontends, each service is independently bundled. If auth-service bundles Zustand v4.3 and cart-service bundles v4.5, you get two separate stores. Synchronizing them introduces a distributed state problem worse than what we started with.
The EventBus works because:
window is the one truly shared singleton. Every microfrontend, regardless of framework or bundle, accesses the same window object.window, and you see every registered listener. Or add EventBus.on("*", console.log) to trace everything.Services only need to know the channel name and payload shape to participate. They subscribe to cart:add or auth:login, not read from window.__mfe_something__. If a service mounts after an event already fired, the shell replays current state by re-emitting. This keeps services decoupled from storage implementation.
Decision: All implementations use history.pushState() / popstate with proper URL paths (/products, /cart). No #hash routing.
Why not hash routing?
Hash routing (/#/products) avoids server configuration since the browser never sends the hash to the server, but has trade-offs:
Dev tooling caveat: vite preview serves static files literally, so navigating to /products returns 404 because no such file exists. This is a known limitation of vite preview: it doesn't support SPA fallback out of the box. A small Vite plugin works around this in development by rewriting non-asset requests to /index.html:
function spaFallback() {
return {
name: 'spa-fallback',
configurePreviewServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url && !req.url.includes('.') && !req.url.startsWith('/assets/')) {
req.url = '/';
}
next();
});
},
};
}
This only applies to the Vite and Svelte implementations during local development. In production, SPA fallback is a standard web server config (e.g. Nginx try_files $uri /index.html). Next.js and Angular dev servers handle SPA routing natively.
Decision: Every exposed component follows a mount(container) / unmount() contract. The shell creates a <div>, calls mount(div), and calls unmount() on navigation.
export function mount(container: HTMLElement): void {
// Render into container
}
export function unmount(): void {
// Clean up event listeners, timers, subscriptions
}
Alternatives rejected:
| Approach | Why Rejected |
|---|---|
| iframes | No shared state, no shared styles, poor performance (each iframe is a full browser context), accessibility nightmare |
| Web Components | Shadow DOM isolates styles too much: we want shared Tailwind classes. Svelte and React lack first-class Web Component consumer support. |
| Import and render directly | Tight coupling. Shell would need to know each remote's framework to render it. |
The mount/unmount pattern is framework-agnostic. React calls ReactDOM.createRoot(container).render(<App />). Svelte calls new App({ target: container }). Vanilla TS calls container.appendChild(el). The shell doesn't know or care: it just passes a <div>.
Why unmount() matters: Without it, navigating from /products to /cart leaves the product component's event listeners, intervals, and EventBus subscriptions running. After 10 navigations, you have 10 zombie components leaking memory.
Decision: All three implementations use Tailwind CSS v4 with @tailwindcss/vite.
The hardest CSS problem in microfrontends is style isolation vs. style consistency. You want services to look the same but not break each other's styles.
| Approach | Problem |
|---|---|
| Global CSS | Service A adds .card { padding: 16px }, Service B adds .card { padding: 24px }. Last one loaded wins. |
| CSS Modules | Solves isolation but not consistency. Each service defines its own colors and spacing. They drift apart. |
| CSS-in-JS | Runtime overhead, SSR complexity, framework-specific. |
| Tailwind | Utility classes are deterministic: p-6 always means padding: 1.5rem everywhere. No conflicts. Consistency by default. |
Tailwind also solves the design token distribution problem. Instead of maintaining a shared design-tokens.css package (another coordination bottleneck), the tokens live in Tailwind's utility system. bg-[#1a1a2e] means the same thing in every service without any shared package.
CSS duplication: Each service bundles its own Tailwind output (~10-15KB gzipped per service). In production, this can be optimized by treating the design system CSS as a shared runtime dependency - similar to how Linux shared objects (.so) work. The shell loads the base CSS once, and remotes declare it as an external dependency rather than bundling their own copy. This follows the same pattern already used for react and react-dom via Module Federation's singleton: true. For this demo, each service bundles independently for simplicity.
Decision: The shared/ package contains only three things: TypeScript types, the EventBus, and the auth store. No UI components. No business logic. No utilities.
Every line of code in shared/ is a coordination tax. When you change EventBus, every service must update. If you put a <Button> component in shared, you've recreated the monolith: the "shared component library" that 4 teams fight over.
The litmus test: "If team A changes this, does team B need to redeploy?" If yes, it shouldn't be in shared. Types are fine (build-time only). EventBus is fine (30 lines, changes maybe once a year). A button component is not.
In production, shared UI components go in a versioned npm package (e.g., @yourco/[email protected]). Each service pins its own version and upgrades on its own schedule, unlike a local shared/ directory where everyone is always on HEAD.
Note: npm packages alone still mean each service bundles its own copy, even if versions match. To deduplicate at runtime, combine with Module Federation's shared config (singleton: true). This is the same mechanism already used for react and react-dom in these demos. The npm package handles versioning and type-checking at build time; Module Federation handles deduplication at runtime.
| Service | Data Owned | UI Components | API Surface |
|---|---|---|---|
| Auth | User validation, demo users | LoginForm, UserMenu (header widget) | /api/manifest, /api/login |
| Product | Product catalog (8 items) | ProductCatalog, ProductDetail | /api/manifest, /api/products, /api/products/:id |
| Cart | Cart items per user | CartPage, MiniCart (header widget) | /api/manifest, /api/cart, /api/cart/count |
Each service owns both the data and the UI for its domain. The product team doesn't just build a product API: they build the product list page, the product detail page, the search/filter UI. This is the key difference between microfrontends and "frontend + microservices backend." The team can change their database schema and their UI in the same PR.
| Service | Next.js | Vite | Svelte | Angular | Mixed |
|---|---|---|---|---|---|
| Shell / Host | 3000 | 5000 | 4000 | 6010 | 7000 |
| Auth Service | 3001 | 5001 | 4001 | 6001 | 7001 |
| Product Service | 3002 | 5002 | 4002 | 6002 | 7002 |
| Cart Service | 3003 | 5003 | 4003 | 6003 | 7003 |
| Aspect | Next.js | Vite (Vanilla TS) | Svelte | Angular | Mixed |
|---|---|---|---|---|---|
| Integration | Module Federation | Module Federation | Module Federation | Module Federation (webpack) | IIFE + mount/unmount registry |
| Rendering | SSR + CSR hybrid | CSR | CSR | CSR | CSR |
| Component Model | React JSX | Raw DOM APIs | Svelte components | Angular standalone components | React + Svelte + Vue |
| State Sharing | EventBus + cookie | EventBus | EventBus | EventBus | EventBus |
| Routing | File-based (Pages Router) | Manual History API | Manual History API | Angular Router | Manual History API |
| SSR Support | Yes (shell only) | No | No | No | No |
| Bundle Size | Larger (React + Next.js) | Smallest (no framework) | Small (Svelte compiles away) | Medium (Angular) | Varies per service |
| Team Autonomy | Medium (same framework) | Medium (same tooling) | Medium (same tooling) | Medium (same framework) | High (any framework) |
Module Federation with SSR (Next.js pattern) when:
getServerSideProps)Module Federation, CSR only (Vite/Svelte pattern) when:
Module Federation with Angular when:
Cross-framework (mixed pattern) when:
The honest answer for most teams: Start with a CSR-only, same-framework approach (Vite or Svelte). It's simpler to reason about, simpler to debug, and simpler to deploy. Add SSR via Next.js when you need SEO. Go cross-framework only when organizational constraints demand it, because the technical complexity is real.
Configuring shared: ["svelte"] in the federation plugin to deduplicate the Svelte runtime caused "Function called outside component initialization" errors.
Root cause: The federation plugin shared svelte but not svelte/internal. Each service got its own copy of svelte/internal (which holds current_component), but the shared copy of svelte (which holds onMount/onDestroy). These two copies have different current_component references, so lifecycle hooks couldn't find the active component.
Fix: shared: []. Each service bundles its own Svelte runtime. ~15KB duplication per service, zero runtime bugs.
Lesson: "Sharing framework runtime" is Module Federation's most-touted feature and its most common footgun. Test thoroughly before enabling it.
import() must be static strings for Module Federation// DOESN'T WORK: federation plugin can't transform template literals at build time
const mod = await import(`${remoteName}/manifest`);
// WORKS: federation plugin matches this exact string and replaces it
const mod = await import("authService/manifest");
Module Federation is a build-time transform. The plugin scans your source for import("remoteName/exposedModule") and replaces it with its own __federation_method_getRemote() call. A dynamic variable defeats the static analysis.
Workaround: Use a switch/if-else to map remote names to static imports.
The mixed implementation proves that MFE services can use entirely different frameworks (React, Svelte, Vue). This works because:
mount(container). It doesn't know or care if React, Svelte, or Vue renders inside that div.window work regardless of framework.The trade-off is bundle size: each service includes its own framework runtime. But in practice, React (40KB gzipped) + Svelte (5KB) + Vue (~30KB) is still smaller than many monolith bundles.
When this makes sense: Company acquisitions (Team A uses React, acquired Team B uses Vue), gradual migrations, or teams that genuinely work better with different tools. Don't do this just because you can. Same-framework MFE is simpler.
# Next.js (ports 3000-3003): dev mode, no build needed
cd nextjs
npm install
for dir in apps/auth-service apps/product-service apps/cart-service apps/shell; do
(cd $dir && npm run dev &)
done
# Vite (ports 5000-5003): requires build for federation
cd vite
npm install
for dir in apps/auth-service apps/product-service apps/cart-service apps/shell; do
(cd $dir && npm run build && npm run preview &)
done
# Svelte (ports 4000-4003): requires build for federation
cd svelte
npm install
for dir in apps/auth-service apps/product-service apps/cart-service apps/shell; do
(cd $dir && npm run build && npm run preview &)
done
# Angular (ports 6000-6003): webpack dev server
cd angular
npm install
for dir in apps/auth-service apps/product-service apps/cart-service apps/shell; do
(cd $dir && npm run dev &)
done
# Mixed / Cross-framework (ports 7000-7003): build services, then run all
cd mixed
npm install
# Build services first (they produce IIFE bundles)
for dir in apps/auth-service apps/product-service apps/cart-service; do
(cd $dir && npm run build)
done
# Preview services + run shell
for dir in apps/auth-service apps/product-service apps/cart-service; do
(cd $dir && npm run preview &)
done
(cd apps/shell && npm run dev &)
This architecture adds real complexity. Don't use it unless you have:
If you're a team of 5 building a product, a well-structured monolith with good module boundaries will outperform microfrontends every time. The overhead of separate builds, separate deploys, cross-service communication, and independent testing is only worth it when the alternative (coordinating 30 engineers on one codebase) is worse.