microfrontend-demo Svelte Themes

Microfrontend Demo

5 microfrontend e-commerce implementations (Next.js, Vite, Svelte, Angular, Mixed) built to understand MFE architecture patterns

Microfrontend E-Commerce Platform

The Problem That Created Microfrontends

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.

But HOW? The Frontend Isn't the Backend

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:

  • Terrible UX: full page reload on every navigation, no shared state between pages, the cart badge disappears when you're on the product page
  • Jarring: different domains, different loading times, different style inconsistencies
  • Impossible for shared UI: who renders the header? The sidebar? If it's the auth page, does it know about cart count?

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:

  1. Composition: multiple teams' code running on the same page, in the same DOM, without breaking each other
  2. Routing: one URL bar, one browser history, but different teams own different routes
  3. Shared state: the cart badge in the header (owned by cart team) needs to update when someone clicks "Add to Cart" on the product page (owned by product team)
  4. Consistent look: if auth team uses 16px padding and product team uses 24px, users notice immediately (unlike backend APIs where nobody sees the JSON formatting)

The Real-World Trigger

The pattern didn't come from a whitepaper. It came from pain:

  • IKEA had 50+ product teams across 48 countries contributing to one website. Shared component upgrades broke unrelated pages. Teams waited for deploy slots. They moved to server-side composition using Edge Side Includes (ESI) so each team could deploy independently. (IKEA engineering blog, InfoQ coverage)
  • Spotify built their desktop app with "spotlets": micro-apps owned by different squads (playlist, search, player), each running in its own iframe inside Chromium Embedded Framework. Each squad shipped without a synchronized release. (Spotify engineering blog)
  • Zalando (EU's largest fashion e-commerce) built Project Mosaic to stitch HTML fragments from multiple services into a single page, enabling independent team deployments at scale. They later evolved this into a rendering engine with GraphQL-based data dependencies. (Zalando engineering blog, Project Mosaic)

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.

What Microfrontends Actually Solve

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.

What Microfrontends Don't Solve

  • They don't make your app faster. More HTTP requests for remote modules, more JavaScript downloaded. There are mitigation strategies (shared runtime, preloading), but a monolith will always have a smaller total bundle.
  • They don't reduce total complexity. They redistribute it. Instead of complex code in one repo, you get complex infrastructure across many repos. You trade merge conflicts for network failures and version mismatches.
  • They add operational overhead. Separate builds, separate deploys, separate monitoring, service discovery, cross-service debugging. This overhead is constant whether you have 2 services or 20. If your team isn't already struggling with coordination, that overhead costs more than it saves.

Why This Project Exists

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:

  1. 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.

  2. 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.

  3. 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

Project Structure

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

Component Organization (Domain-Based Atomic Design)

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.


Architecture

                          +------------------------------+
                          |          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         |
    +---------------------+ +-------------------+ +---------------------+

Design Principles

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):

  • Business logic. Cart-service calculates totals. Product-service handles search/filter. Neither imports the other's code.
  • UI components. Each service builds its own pages. There's no <SharedButton> that 3 teams fight over.
  • Data fetching. Product-service knows how to fetch products. Cart-service knows how to manage cart items. The shell doesn't know or care about either's data model.

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

Architectural Decisions

ADR-1: Manifest-Based Service Discovery over Hardcoded Routes

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

ADR-2: Progressive Loading, Not Blocking Discovery

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:

  • First paint is instant: the shell layout (header, sidebar skeleton, main area) renders in <50ms
  • Slow services don't penalize fast ones: if auth takes 2 seconds but products responds in 50ms, you see Products immediately
  • Down services are invisible: if cart-service is offline, there's no "Cart" in the nav. No error, no spinner. Users don't miss what they never saw.

Trade-off accepted: Brief "pop-in" as nav items appear asynchronously. In practice <100ms and barely noticeable.


ADR-3: Module Federation as Primary Integration, with IIFE Alternative for Cross-Framework

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):

  • Shell and remotes are entirely client-side
  • No server rendering; the browser fetches and mounts everything

SSR + CSR Hybrid (Next.js):

  • Shell server-renders the page skeleton via getServerSideProps
  • Remote components load client-side via next/dynamic with ssr: false
  • Uses @module-federation/nextjs-mf (NextFederationPlugin) with Pages Router

Trade-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).


ADR-4: EventBus over Shared State Library

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.
  • Events are loosely coupled. Product service doesn't import anything from cart-service. It fires an event. If cart-service is down, the event goes nowhere. No import error, no crash.
  • Events are debuggable. Open DevTools, check Event Listeners on 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.


ADR-5: History API Routing over Hash Navigation

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:

  1. Analytics tools struggle. Google Analytics, Mixpanel, and most tracking tools have edge cases with hash-based URLs.
  2. SSR doesn't work. The server never sees the hash, so it can't pre-render the right page.
  3. Sharing links is fragile. Some platforms strip the hash from URLs when sharing.

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.


ADR-6: mount/unmount Lifecycle over iframes or Web Components

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.


ADR-7: Tailwind CSS over Component-Scoped Styles

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.


ADR-8: Minimal Shared Package

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 Ownership

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.

Ports

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

Framework Comparison

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)

When to Choose What

Module Federation with SSR (Next.js pattern) when:

  • SEO matters (product pages, landing pages, content sites)
  • You want server-rendered initial page loads with client-side remote components
  • You need auth data available on first render (cookie read in getServerSideProps)
  • You're already invested in the React/Next.js ecosystem

Module Federation, CSR only (Vite/Svelte pattern) when:

  • You're building a dashboard, admin panel, or internal tool where SEO doesn't matter
  • Teams share the same build tooling and want sub-second page transitions
  • You need real-time widget composition (multiple teams contributing to the same page)
  • Latency matters: no server round-trip for every navigation

Module Federation with Angular when:

  • Your organization is already invested in Angular
  • You want strong opinions on project structure, DI, and routing out of the box
  • Enterprise teams that value Angular's opinionated architecture

Cross-framework (mixed pattern) when:

  • Teams genuinely need different frameworks (acquired company uses Vue, existing team uses React)
  • You want maximum team autonomy, where each service picks its own tech stack
  • You're OK with each service bundling its own framework runtime (larger total bundle)
  • The trade-off: more JavaScript downloaded, but each team is truly independent

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.


Lessons Learned

Svelte shared runtime doesn't work with Module Federation

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.

Dynamic 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.

Cross-framework MFE works because services are independent

The mixed implementation proves that MFE services can use entirely different frameworks (React, Svelte, Vue). This works because:

  1. The mount/unmount contract is framework-agnostic. The shell calls mount(container). It doesn't know or care if React, Svelte, or Vue renders inside that div.
  2. Each service bundles its own runtime. No shared React or Vue instance. Each IIFE bundle is self-contained.
  3. EventBus is the only shared dependency. CustomEvents on 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.


How to Run

Quick Start

# 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 &)

When NOT to Use Microfrontends

This architecture adds real complexity. Don't use it unless you have:

  • A mental problem. Like, it looks good on toilet paper. Which is where it belongs ;)
  • Multiple teams (3+) that need to deploy independently
  • Domain boundaries that are clear and stable (auth, products, cart, not "header component shared by everyone")
  • Sufficient scale that deployment coordination is your bottleneck, not feature development

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.

Top categories

Loading Svelte Themes