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.
The road here
Four eras of frontend architecture
- 1990s
Static Pages
Simple HTML and CSS with minimal JavaScript.
- 2000s
Dynamic Web Apps
Server-side rendering with PHP, ASP.NET, JSP.
- 2010s
Single Page Apps
React, Angular, Vue: rich client-side experiences.
- 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.
Technology independence
Each team picks its own stack: React, Vue, Angular, or vanilla JS.
Independent deployment
Ship a feature without redeploying the entire application.
Team autonomy
Teams own their whole slice, from UI to backend services.
Fault isolation
If one micro frontend fails, the others keep working.
Why it matters
What changes when you split the monolith
Proof in production
The payoff, measured
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 (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.
// 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.
// 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.
// 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.
// 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 appSelective builds & deploys (Turbo)
Detect which remotes actually changed and rebuild / redeploy only those, instead of everything on every push.
#!/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
:
doneShould 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
- 01Start with organisational needs; micro frontends solve people problems, not just technical ones.
- 02Invest in tooling and automation early; it's essential to success.
- 03Focus on communication, both APIs and teams.
- 04Measure everything, so the architecture keeps proving its value.
- 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.