Skip to content

Guide

Complete Micro Frontend Architecture Guide

From monoliths to micro frontends: how to build scalable, maintainable, independently deployable frontends, with live demos and a decision framework.

Module FederationViteVue · React · Angular2025

The road here

Four eras of frontend architecture

  1. 1990s

    Static Pages

    Simple HTML and CSS with minimal JavaScript.

  2. 2000s

    Dynamic Web Apps

    Server-side rendering with PHP, ASP.NET, JSP.

  3. 2010s

    Single Page Apps

    React, Angular, Vue: rich client-side experiences.

  4. 2020s

    Micro Frontends

    Modular, scalable, independently deployable architectures.

What they are

Microservices, brought to the frontend

Instead of one monolithic frontend, the interface is split into independent apps that separate teams build, test and ship on their own. Four principles hold it together.

01

Technology independence

Each team picks its own stack: React, Vue, Angular, or vanilla JS.

02

Independent deployment

Ship a feature without redeploying the entire application.

03

Team autonomy

Teams own their whole slice, from UI to backend services.

04

Fault isolation

If one micro frontend fails, the others keep working.

In production atNetflixSpotifyIKEAUpwork

Why it matters

What changes when you split the monolith

Every change needs a full redeploy
Independent, per-feature deployment
Locked into the original tech choices
Framework flexibility per team
Teams stepping on each other's toes
Clear ownership & autonomy
One area breaks seemingly unrelated features
Isolated blast radius & testing
Weeks to onboard into one huge codebase
Small, comprehensible surfaces
One bug can take down the whole app
Graceful, incremental migration

Proof in production

The payoff, measured

50%
Less development time
IKEA
75%
Faster page load
IKEA
40%
Faster time-to-market
Spotify
Weekly → daily
Deploy frequency
Netflix

How to build it

Six approaches, one clear winner

Every approach trades setup effort against independence. For modern SPAs, Module Federation leads.

Server-Side

Setup
Simple
Best for
Traditional web apps

Build-Time

Setup
Simple
Best for
Component libraries

IFrames

Setup
Moderate
Best for
Legacy integration

Web Components

Setup
Moderate
Best for
Framework-agnostic UI

Client Routing

Setup
Moderate
Best for
Page-based apps

Module Federation

Recommended
Setup
Advanced
Best for
Modern SPAs

Why Module Federation wins

  • Runtime flexibility: load modules on demand, no rebuild
  • Intelligent shared dependencies for optimised bundles
  • True deployment independence
  • Framework-agnostic: React, Vue, Angular and more
  • A strong, growing ecosystem and tooling

See it running

Live micro-frontend demos

Three runnable examples built with Vue and Module Federation. Launch each when you want it; they load on demand.

Canvas demo: shared state across apps. Independently built modules drawing to one shared canvas via module federation.

Counter demo: interface-based communication. Remotes talk through a shared interface with real-time sync and graceful degradation.

Browse the full source. The complete shell + remotes repository, opened in an in-browser VS Code.

For the builders

The actual implementation

Everything above is enough to decide. This is the real code behind the demos (module federation config, store sharing, interface communication and more), kept collapsed so it stays out of your way until you want it.

Configuring Module Federation (Vite)

The shell app names itself, exposes local modules, and declares shared dependencies so remotes load on demand.

vite.config.js
// vite.config.js (example from Shell App)
import { federation } from '@module-federation/vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    vue(),
    federation({
      name: 'shellApp',                  // this app's name
      filename: 'remoteEntry.js',        // output file others will load
      exposes: {                         // local modules we share
        './counterInterface': './src/interfaces/counter.js',
      },
      remotes: {                         // apps we depend on
        demoCounterApp: {
          type: 'module',
          entry: process.env.VITE_DEMO_COUNTER_REMOTE_ENTRY,
        }
      },
      shared: {                          // singletons across apps
        vue: { singleton: true, requiredVersion: '^3.5.18' },
        'vue-router': { singleton: true, requiredVersion: '^4.2.4' },
        pinia: { singleton: true, requiredVersion: '^2.1.7' },
        fabric: { singleton: true, requiredVersion: '^5.3.0' }
      }
    })
  ],
  optimizeDeps: {
    include: ['vue', 'vue-router', 'pinia', 'fabric'],
  }
})
Sharing a store across apps

Expose a Pinia store from one app and consume it in another: shared state without prop-drilling across remotes.

ts
// Shell App - Pinia Store (src/stores/common.store.js)
import { defineStore } from 'pinia'

export const useCommonStore = defineStore('common', {
  state: () => ({
    num: 0
  }),

  actions: {
    increment() {
      this.num++
      console.log('Incremented to:', this.num)
    },

    decrement() {
      this.num--
      console.log('Decremented to:', this.num)
    },

    setNum(value) {
      this.num = value
      console.log('Set to:', this.num)
    },

    incrementBy(amount) {
      this.num += amount
      console.log('Incremented by', amount, 'to:', this.num)
    }
  },

  getters: {
    doubleNum: (state) => state.num * 2,
    isPositive: (state) => state.num > 0,
    isNegative: (state) => state.num  Math.abs(state.num)
  }
})

// Importing a counter store exposed by demoCounterApp
import { useCounterStore } from 'demoCounterApp/counterInterface'

export default {
  setup() {
    const counter = useCounterStore()
    return { counter, increment: counter.increment }
  }
}
Exposing & consuming a Vue component

Expose a component (here a Fabric.js canvas) from one remote and import it into another at runtime.

vue
// DynamicCanvas.vue
<template>
  <div ref="canvasContainer" class="canvas-wrapper"></div>
</template>

<script setup>
import { onMounted, ref } from 'vue'
import { fabric } from 'fabric'

const canvasContainer = ref(null)

onMounted(() => {
  const canvas = new fabric.Canvas(canvasContainer.value, {
    backgroundColor: '#fff',
    selection: true
  })

  canvas.add(new fabric.Rect({
    left: 100, top: 100,
    fill: 'red', width: 60, height: 70
  }))
})
</script>

exposes: {
  './DynamicCanvas': './src/components/DynamicCanvas.vue'
}

// Importing exposed component
import DynamicCanvas from 'demoOneApp/DynamicCanvas'
Interface-based communication

Remotes talk through a stable shared interface: real-time sync with graceful degradation if a remote is down.

js
// Shell App - Interface Definition (src/interfaces/counter.js)
import { useCommonStore } from '../stores/common.store.js'
import { getActivePinia } from 'pinia'

export const counterInterface = {
  // Check if interface is ready
  isReady() {
    try {
      return !!getActivePinia()
    } catch {
      return false
    }
  },

  // Get current counter value
  getValue() {
    const store = useCommonStore()
    return store.num
  },

  // Modify counter state
  increment() {
    const store = useCommonStore()
    store.increment()
  },

  decrement() {
    const store = useCommonStore()
    store.decrement()
  },

  // Subscribe to real-time changes
  subscribe(callback) {
    const store = useCommonStore()
    return store.$subscribe((mutation, state) => {
      callback(state.num, mutation)
    })
  }
}

// Shell App - Module Federation Configuration
// vite.config.js
exposes: {
  "./interfaces": "./src/interfaces/index.js",
  "./counterInterface": "./src/interfaces/counter.js",
}

// Demo Counter App - Interface Usage
// src/components/CounterDemo.vue
const connectToShell = async () => {
  try {
    // Dynamic import from Shell App
    const { counterInterface } = await import('shellApp/interfaces')

    // Subscribe to real-time changes
    unsubscribe = counterInterface.subscribe((newValue) => {
      currentValue.value = newValue
      addToLog(`Value changed to ${newValue}`)
    })

    // Get initial value
    currentValue.value = counterInterface.getValue()
    isConnected.value = true
  } catch (error) {
    console.error('Interface connection failed:', error)
  }
}
Styling across remotes

Each remote emits one CSS file; the host loads remote CSS before mounting so nothing renders unstyled on first paint.

ts
// vite.config.ts (in each remote app)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    // Emit a single combined CSS file for this remote
    cssCodeSplit: false,
    rollupOptions: {
      output: {
        // Give CSS a predictable name so the shell can reference it
        assetFileNames: (assetInfo) => {
          if (assetInfo.name && assetInfo.name.endsWith('.css')) {
            return 'assets/remote.css' // or `${name}.css` per app
          }
          return 'assets/[name]-[hash][extname]'
        },
      },
    },
  },
})

// shell-app/src/main.js (build behavior)
const remotes = [
  { name: 'demo-one',   href: import.meta.env.VITE_DEMO_ONE_CSS_URL },
  { name: 'demo-two',   href: import.meta.env.VITE_DEMO_TWO_CSS_URL },
  { name: 'demo-three', href: import.meta.env.VITE_DEMO_THREE_CSS_URL },
  { name: 'demo-counter', href: import.meta.env.VITE_DEMO_COUNTER_CSS_URL },
]

for (const r of remotes) {
  const link = document.createElement('link')
  link.rel = 'stylesheet'
  link.href = r.href
  link.onload = () => console.log(`✅ ${r.name} CSS loaded`)
  link.onerror = () => console.error(`❌ Failed loading ${r.name} CSS`)
  document.head.appendChild(link)
}

// Then mount the shell app
Selective builds & deploys (Turbo)

Detect which remotes actually changed and rebuild / redeploy only those, instead of everything on every push.

bash
#!/usr/bin/env bash
set -euo pipefail

BASE_REF="${BASE_REF:-origin/main}"
APPS_ROOT="${APPS_ROOT:-apps}"

echo "▶️ Building only affected projects since ${BASE_REF}"
npx turbo run build --since="${BASE_REF}" --output-logs=errors-only

echo "🔎 Detecting which apps changed since ${BASE_REF}"
changed_apps=()
for app in "${APPS_ROOT}"/*; do
  [ -d "${app}" ] || continue
  [ -f "${app}/package.json" ] || continue
  if ! git diff --quiet "${BASE_REF}"..HEAD -- "${app}"; then
    changed_apps+=("$(basename \"${app}\")")
  fi
done

if [ ${#changed_apps[@]} -eq 0 ]; then
  echo "✅ No app changes detected; skipping deploy."
  exit 0
fi

echo "🚀 Deploying changed apps: ${changed_apps[*]}"
for app in "${changed_apps[@]}"; do
  echo "--- Deploying $app ---"
  # Example: pnpm --filter "$app" run deploy
  # Or:      npm run -w "${APPS_ROOT}/$app" deploy
  :
done

Should you?

A decision framework

Reach for them when

  • Large teams (8+ developers) on one frontend
  • Complex apps (50k+ lines) past manageable size
  • Clear business-domain boundaries
  • Teams that want different frameworks
  • Independent, rapid feature deployment
  • Gradually modernising a legacy monolith

Skip them when

  • Small apps with limited complexity
  • Small teams (< 8 developers)
  • Heavily interdependent features
  • Performance-critical, millisecond-sensitive work
  • Prototypes and MVPs that must move fast
  • Short-term projects not worth the investment

If you commit

  1. 01Start with organisational needs; micro frontends solve people problems, not just technical ones.
  2. 02Invest in tooling and automation early; it's essential to success.
  3. 03Focus on communication, both APIs and teams.
  4. 04Measure everything, so the architecture keeps proving its value.
  5. 05Be patient; benefits often take 6 to 12 months to fully materialise.

Thinking about it?

Untangling a frontend monolith?

We modernise large frontends incrementally, adding micro frontends where they earn their keep, not everywhere. Tell us where yours is today.

Senior engineers reply within one business day.