Is your frontend application starting to feel like a monolithic beast? Are development cycles slowing down, and teams stepping on each other's toes? If you answered yes, you might want to explore Module Federation, a powerful Webpack 5 feature that's revolutionizing how we build frontends. Let's dive into what Module Federation is, why it matters, and how it can transform your development workflow — then we'll build a complete working example end-to-end.
What Exactly is Module Federation?
Introduced in Webpack 5, Module Federation allows for the independent deployment of different parts of an application. It enables code sharing between multiple builds at runtime, essentially forming the foundation of Micro Frontend Architecture. Think of it as breaking down a large, single application into smaller, manageable pieces that can be developed and deployed separately.
Why Embrace Module Federation?
- Break monolithic frontends — divide large applications into independent modules, making them easier to manage and scale.
- Independent teams — enable teams to develop, test, and deploy features separately, increasing development velocity.
- Code sharing — share common code like design systems or utility functions across different applications, reducing redundancy.
- Scalability — ideal for large organizations or applications that have grown too complex.
- Industry adoption — leading companies like DAZN, Adidas, Epic Games, SAP, and Zalando leverage Module Federation to power their platforms.
Architectural Overview
At its core, Module Federation involves three roles:
- Host (Container) — the main application that loads remote modules.
- Remote(s) — separate applications exposing modules/components.
- Shared Libraries — dependencies (Vue, React, Pinia, etc.) that both host and remotes share as singletons.
How It Works
Remote applications expose modules/components using the exposes config. The host application consumes those modules via remotes. The plugin handles the handshake, ensuring the host knows where to fetch modules from at runtime. Dependencies are treated as singletons to avoid version conflicts.
┌──────────────────┐ ┌──────────────────┐
│ todo-host │ ◄──┐ │ todo-remote │
│ (port 5173) │ │ │ (port 5174) │
│ │ │ │ exposes: │
│ consumes: │ ├───│ ./TodoList │
│ todo-remote │ │ └──────────────────┘
│ todo-stat │ │
│ │ │ ┌──────────────────┐
│ │ │ │ todo-stat │
│ │ │ │ (port 5175) │
│ │ └───│ exposes: │
│ │ │ ./TodoStats │
└────────┬─────────┘ └──────────────────┘
│
▼
┌──────────────────┐ ┌──────────────────┐
│ shared store │ │ backend │
│ (Pinia) │ ─────► │ Express+SQLite │
│ single source │ │ (port 3000) │
│ of truth │ └──────────────────┘
└──────────────────┘
The Demo: Vue 3 + Vite Todo App
Here's the scenario we're going to build:
todo-host— the shell that dynamically pulls in the UI and stats remotes at runtime.todo-remote— exposes aTodoListcomponent for task management.todo-stat— exposes aTodoStatscomponent for displaying task statistics.shared/todo-store— a shared Pinia store used as the single source of truth.backend— an Express + SQLite server handling RESTful CRUD operations.
📦 Repo: https://github.com/bishrulhaq/module-federation-todo-app
Project layout:
module-federation-todo-app/
├── todo-host/ # Main container (port 5173)
├── todo-remote/ # Todo UI components (port 5174)
├── todo-stat/ # Statistics module (port 5175)
├── backend/ # Express + SQLite (port 3000)
├── shared/
│ └── todo-store/ # Shared Pinia store
├── install-all.bat
└── start-all.bat
1. The Backend — Express + SQLite
The backend is intentionally small. Its only job is to persist todos and serve REST endpoints the frontends can hit.
backend/package.json
{
"name": "todo-backend",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"dev": "nodemon server.js",
"start": "node server.js"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"cors": "^2.8.5",
"express": "^4.19.2"
},
"devDependencies": {
"nodemon": "^3.1.4"
}
}
backend/server.js
const express = require('express');
const cors = require('cors');
const Database = require('better-sqlite3');
const path = require('path');
const app = express();
const db = new Database(path.join(__dirname, 'db.sqlite'));
app.use(cors());
app.use(express.json());
// --- schema ---------------------------------------------------------------
db.exec(`
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// --- helpers --------------------------------------------------------------
const toTodo = (row) => ({
id: row.id,
title: row.title,
completed: !!row.completed,
createdAt: row.created_at,
});
// --- routes ---------------------------------------------------------------
app.get('/api/todos', (req, res) => {
const rows = db.prepare('SELECT * FROM todos ORDER BY id DESC').all();
res.json(rows.map(toTodo));
});
app.post('/api/todos', (req, res) => {
const { title } = req.body || {};
if (!title || !title.trim()) {
return res.status(400).json({ error: 'title is required' });
}
const info = db
.prepare('INSERT INTO todos (title, completed) VALUES (?, 0)')
.run(title.trim());
const row = db.prepare('SELECT * FROM todos WHERE id = ?').get(info.lastInsertRowid);
res.status(201).json(toTodo(row));
});
app.patch('/api/todos/:id', (req, res) => {
const { id } = req.params;
const existing = db.prepare('SELECT * FROM todos WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'not found' });
const title = req.body.title ?? existing.title;
const completed =
typeof req.body.completed === 'boolean'
? req.body.completed ? 1 : 0
: existing.completed;
db.prepare('UPDATE todos SET title = ?, completed = ? WHERE id = ?')
.run(title, completed, id);
const row = db.prepare('SELECT * FROM todos WHERE id = ?').get(id);
res.json(toTodo(row));
});
app.delete('/api/todos/:id', (req, res) => {
const { id } = req.params;
const info = db.prepare('DELETE FROM todos WHERE id = ?').run(id);
if (info.changes === 0) return res.status(404).json({ error: 'not found' });
res.status(204).end();
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`API listening on :${PORT}`));
That's the entire API surface: GET, POST, PATCH, DELETE over /api/todos.
2. The Shared Pinia Store
This is the glue that makes the micro-frontends feel like one app. All three Vue apps import the same store module, so toggling a todo in the todo-remote UI instantly updates counts in todo-stat — without any prop-drilling or event bus between apps.
shared/todo-store/package.json
{
"name": "@todo/store",
"version": "1.0.0",
"main": "src/index.js",
"type": "module",
"peerDependencies": {
"pinia": "^2.2.0",
"vue": "^3.4.0"
}
}
shared/todo-store/src/index.js
import { defineStore } from 'pinia';
const API = 'http://localhost:3000/api/todos';
export const useTodoStore = defineStore('todos', {
state: () => ({
items: [],
loading: false,
error: null,
}),
getters: {
total: (s) => s.items.length,
completed: (s) => s.items.filter((t) => t.completed).length,
pending: (s) => s.items.filter((t) => !t.completed).length,
percentDone: (s) =>
s.items.length === 0
? 0
: Math.round(
(s.items.filter((t) => t.completed).length / s.items.length) * 100
),
},
actions: {
async fetchAll() {
this.loading = true;
this.error = null;
try {
const res = await fetch(API);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.items = await res.json();
} catch (e) {
this.error = e.message;
} finally {
this.loading = false;
}
},
async add(title) {
const res = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
const todo = await res.json();
this.items.unshift(todo);
},
async toggle(id) {
const t = this.items.find((x) => x.id === id);
if (!t) return;
const res = await fetch(`${API}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !t.completed }),
});
const updated = await res.json();
Object.assign(t, updated);
},
async remove(id) {
await fetch(`${API}/${id}`, { method: 'DELETE' });
this.items = this.items.filter((t) => t.id !== id);
},
},
});
The host initializes Pinia once. Both remotes receive the exact same store instance because pinia is declared as a shared singleton in every vite.config.js.
3. The Host Application — todo-host
The host owns the page shell, initializes Pinia, and lazy-loads both remotes.
todo-host/package.json
{
"name": "todo-host",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port 5173 --strictPort",
"build": "vite build",
"preview": "vite preview --port 5173 --strictPort"
},
"dependencies": {
"pinia": "^2.2.0",
"vue": "^3.4.0",
"@todo/store": "file:../shared/todo-store"
},
"devDependencies": {
"@module-federation/vite": "^1.0.0",
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.3.0"
}
}
todo-host/vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { federation } from '@module-federation/vite';
export default defineConfig({
plugins: [
vue(),
federation({
name: 'todo_host',
remotes: {
todo_remote: {
type: 'module',
name: 'todo_remote',
entry: 'http://localhost:5174/remoteEntry.js',
entryGlobalName: 'todo_remote',
shareScope: 'default',
},
todo_stat: {
type: 'module',
name: 'todo_stat',
entry: 'http://localhost:5175/remoteEntry.js',
entryGlobalName: 'todo_stat',
shareScope: 'default',
},
},
shared: ['vue', 'pinia'],
}),
],
server: { port: 5173, strictPort: true },
build: { target: 'chrome89' },
});
todo-host/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Todo Host</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
todo-host/src/main.js
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
const app = createApp(App);
app.use(createPinia()); // <-- shared Pinia instance for all remotes
app.mount('#app');
todo-host/src/App.vue
<script setup>
import { defineAsyncComponent, onMounted } from 'vue';
import { useTodoStore } from '@todo/store';
// Remotes are dynamically imported at runtime.
const TodoList = defineAsyncComponent(() => import('todo_remote/TodoList'));
const TodoStats = defineAsyncComponent(() => import('todo_stat/TodoStats'));
const store = useTodoStore();
onMounted(() => store.fetchAll());
</script>
<template>
<div class="shell">
<header>
<h1>📝 Federated Todos</h1>
<p class="sub">Host shell loading two independent remotes at runtime</p>
</header>
<main>
<section class="col">
<Suspense>
<TodoList />
<template #fallback><p>Loading list…</p></template>
</Suspense>
</section>
<aside class="col">
<Suspense>
<TodoStats />
<template #fallback><p>Loading stats…</p></template>
</Suspense>
</aside>
</main>
</div>
</template>
<style scoped>
.shell { max-width: 960px; margin: 2rem auto; font-family: system-ui; }
header { text-align: center; margin-bottom: 2rem; }
.sub { color: #666; }
main { display: grid; grid-template-columns: 2fr 1fr; gap: 2rem; }
.col { background: #fff; border-radius: 12px; padding: 1.25rem;
box-shadow: 0 4px 20px rgba(0,0,0,.05); }
</style>
Note the two things that make this work:
defineAsyncComponent(() => import('todo_remote/TodoList'))— the stringtodo_remote/TodoListis resolved by the federation runtime, not by the filesystem.<Suspense>— required because async components are, well, async. You get a fallback while the remote bundle is downloaded.
4. The Remote — todo-remote (exposes TodoList)
todo-remote/package.json
{
"name": "todo-remote",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port 5174 --strictPort",
"build": "vite build",
"preview": "vite preview --port 5174 --strictPort"
},
"dependencies": {
"pinia": "^2.2.0",
"vue": "^3.4.0",
"@todo/store": "file:../shared/todo-store"
},
"devDependencies": {
"@module-federation/vite": "^1.0.0",
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.3.0"
}
}
todo-remote/vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { federation } from '@module-federation/vite';
export default defineConfig({
plugins: [
vue(),
federation({
name: 'todo_remote',
filename: 'remoteEntry.js',
exposes: {
'./TodoList': './src/components/TodoList.vue',
},
shared: ['vue', 'pinia'],
}),
],
server: {
port: 5174,
strictPort: true,
cors: true,
origin: 'http://localhost:5174',
},
build: { target: 'chrome89' },
});
todo-remote/src/components/TodoList.vue
<script setup>
import { ref } from 'vue';
import { useTodoStore } from '@todo/store';
const store = useTodoStore();
const draft = ref('');
async function addTodo() {
const title = draft.value.trim();
if (!title) return;
await store.add(title);
draft.value = '';
}
</script>
<template>
<div class="todo-list">
<h2>Tasks</h2>
<form class="new" @submit.prevent="addTodo">
<input
v-model="draft"
placeholder="What needs to be done?"
aria-label="New todo"
/>
<button type="submit">Add</button>
</form>
<p v-if="store.loading">Loading…</p>
<p v-else-if="store.error" class="error">Error: {{ store.error }}</p>
<p v-else-if="store.items.length === 0" class="empty">
Nothing here yet — add your first task above.
</p>
<ul v-else>
<li v-for="t in store.items" :key="t.id" :class="{ done: t.completed }">
<label>
<input
type="checkbox"
:checked="t.completed"
@change="store.toggle(t.id)"
/>
<span>{{ t.title }}</span>
</label>
<button class="del" @click="store.remove(t.id)" aria-label="delete">
✕
</button>
</li>
</ul>
</div>
</template>
<style scoped>
.todo-list h2 { margin-top: 0; }
.new { display: flex; gap: .5rem; margin-bottom: 1rem; }
.new input { flex: 1; padding: .6rem .8rem; border: 1px solid #ddd;
border-radius: 8px; }
.new button { padding: 0 1rem; border: 0; border-radius: 8px;
background: #4f46e5; color: #fff; cursor: pointer; }
ul { list-style: none; padding: 0; margin: 0; }
li { display: flex; justify-content: space-between;
align-items: center; padding: .5rem 0;
border-bottom: 1px solid #f0f0f0; }
li.done span { text-decoration: line-through; color: #999; }
.del { background: transparent; border: 0; color: #b00;
cursor: pointer; font-size: 1rem; }
.empty, .error { color: #666; font-style: italic; }
</style>
todo-remote/src/App.vue (standalone dev harness)
So the remote can run on its own at http://localhost:5174 during development:
<script setup>
import { onMounted } from 'vue';
import { useTodoStore } from '@todo/store';
import TodoList from './components/TodoList.vue';
const store = useTodoStore();
onMounted(() => store.fetchAll());
</script>
<template>
<div style="max-width:600px;margin:2rem auto;font-family:system-ui">
<h1>Todo Remote (standalone)</h1>
<TodoList />
</div>
</template>
todo-remote/src/main.js
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
createApp(App).use(createPinia()).mount('#app');
5. The Stats Remote — todo-stat (exposes TodoStats)
Nearly identical to todo-remote, but on port 5175 and exposing a different component.
todo-stat/vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { federation } from '@module-federation/vite';
export default defineConfig({
plugins: [
vue(),
federation({
name: 'todo_stat',
filename: 'remoteEntry.js',
exposes: {
'./TodoStats': './src/components/TodoStats.vue',
},
shared: ['vue', 'pinia'],
}),
],
server: {
port: 5175,
strictPort: true,
cors: true,
origin: 'http://localhost:5175',
},
build: { target: 'chrome89' },
});
todo-stat/src/components/TodoStats.vue
<script setup>
import { computed } from 'vue';
import { useTodoStore } from '@todo/store';
const store = useTodoStore();
// Reactive — the moment TodoList mutates the store, these recompute.
const stats = computed(() => ({
total: store.total,
completed: store.completed,
pending: store.pending,
percent: store.percentDone,
}));
</script>
<template>
<div class="stats">
<h2>Stats</h2>
<div class="grid">
<div class="card">
<div class="num">{{ stats.total }}</div>
<div class="lbl">Total</div>
</div>
<div class="card">
<div class="num">{{ stats.completed }}</div>
<div class="lbl">Done</div>
</div>
<div class="card">
<div class="num">{{ stats.pending }}</div>
<div class="lbl">Pending</div>
</div>
</div>
<div class="bar" :title="`${stats.percent}% complete`">
<div class="fill" :style="{ width: stats.percent + '%' }" />
</div>
<div class="pct">{{ stats.percent }}% complete</div>
</div>
</template>
<style scoped>
.stats h2 { margin-top: 0; }
.grid { display: grid; grid-template-columns: repeat(3,1fr); gap: .75rem;
margin-bottom: 1rem; }
.card { background: #f7f7fb; border-radius: 10px; padding: .8rem;
text-align: center; }
.num { font-size: 1.6rem; font-weight: 700; color: #4f46e5; }
.lbl { font-size: .8rem; color: #666; text-transform: uppercase; }
.bar { height: 8px; background: #eee; border-radius: 4px; overflow: hidden; }
.fill { height: 100%; background: #22c55e; transition: width .3s ease; }
.pct { margin-top: .4rem; font-size: .85rem; color: #555; }
</style>
todo-stat/src/App.vue
<script setup>
import { onMounted } from 'vue';
import { useTodoStore } from '@todo/store';
import TodoStats from './components/TodoStats.vue';
const store = useTodoStore();
onMounted(() => store.fetchAll());
</script>
<template>
<div style="max-width:420px;margin:2rem auto;font-family:system-ui">
<h1>Todo Stats (standalone)</h1>
<TodoStats />
</div>
</template>
6. Running It All
The repo ships with Windows batch scripts, but any OS works with four terminals:
# Terminal 1 — backend
cd backend && npm install && npm run dev # :3000
# Terminal 2 — host
cd todo-host && npm install && npm run dev # :5173
# Terminal 3 — remote (UI)
cd todo-remote && npm install && npm run dev # :5174
# Terminal 4 — remote (stats)
cd todo-stat && npm install && npm run dev # :5175
Open http://localhost:5173. The host page loads, fires two import() calls behind the scenes, pulls remoteEntry.js from each of the two remote servers, wires the exposed components into the shell, and — because both remotes import the same @todo/store that the host initialized — all three Vue apps are reading and writing the same Pinia state.
7. Security in Production
When deploying in production, security is paramount. A typical hardened topology:
- Public subnet: only the host (or a CDN serving the host's built assets) is reachable.
- Private subnet: remote apps and the API live here — not directly routable from the internet.
- Reverse proxy (NGINX): terminates TLS and proxies
/remotes/ui/*totodo-remote,/remotes/stats/*totodo-stat, and/api/*to the backend. The browser sees one origin; same-origin eliminates most CORS pain. - CORS rules: when remotes must be cross-origin, pin
Access-Control-Allow-Originto known host domains — never*in production. - Subresource integrity / signed manifests: consider
mf-manifest.jsonwith content hashes so a compromised remote server can't silently ship malicious code. - CSP:
script-srcshould list every origin from which you loadremoteEntry.js.
A minimal NGINX sketch:
server {
listen 443 ssl;
server_name app.example.com;
location /api/ { proxy_pass http://api-internal:3000/; }
location /remotes/ui/ { proxy_pass http://remote-ui-internal:5174/; }
location /remotes/stats/ { proxy_pass http://remote-stats-internal:5175/; }
location / { root /var/www/host-dist; try_files $uri /index.html; }
}
With the remotes behind the proxy, your host's vite.config.js changes to:
remotes: {
todo_remote: { /* ... */ entry: '/remotes/ui/remoteEntry.js', /* ... */ },
todo_stat: { /* ... */ entry: '/remotes/stats/remoteEntry.js', /* ... */ },
}
8. Challenges and Drawbacks
Module Federation is powerful, but it's not free:
- Complex setup. Four moving parts in this tiny demo; real systems have many more. Expect to spend time on the plumbing.
- Version mismatches. Shared libraries (
vue,pinia) must agree on versions across every remote. Singleton enforcement withstrictVersionhelps catch drift early. - State sharing across boundaries. Auth tokens, theme, feature flags — you need a conscious strategy (shared store, event bus, or URL-driven state).
- Bundle size control. Every remote that duplicates an un-shared dep inflates download size. Audit
sharedlists carefully. - Tight coupling. The point of MFEs is independence; if remotes start depending on each other's internals, you've reinvented a monolith with extra HTTP hops.
- DX friction with Vite. HMR across federation boundaries still isn't as smooth as Webpack's. Develop remotes standalone when iterating, integrate in the host when stabilizing.
9. Key Takeaways
- Module Federation enables dynamic code loading and team scalability.
- It's worth considering for large or growing projects — not for your weekend side project.
- It encourages separation of concerns and domain-driven micro-frontends.
- It allows gradual migration from legacy monoliths.
- It ensures runtime flexibility with shared libraries and remote modules.
- It drives organizational scalability across multiple teams and domains.
Start small: carve one domain out of your monolith, stand it up as a remote, consume it from the existing shell. Measure the pain and the payoff before carving out the next one. Micro-frontends are an organizational tool as much as a technical one — reach for them when team coordination costs start to dominate, not before.
References
- Repo: https://github.com/bishrulhaq/module-federation-todo-app
- Module Federation showcase — https://module-federation.io/showcase/index.html
@module-federation/viteplugin — https://module-federation.io/guide/build-plugins/plugins-vite- Zalando's experience — https://engineering.zalando.com/posts/2024/10/building-modular-portal-with-webpack-module-federation.html
- Microfrontends should be your last resort — https://www.breck-mckye.com/blog/2023/05/Microfrontends-should-be-your-last-resort/