Vue.js 78 🟢 Micro-Frontend Architectures with Vue and Module Federation
Micro-frontends apply the principle of service decomposition to the browser. Instead of one monolithic Vue application, the UI is split into independently developed, tested, and deployed fragments that a shell application composes at runtime. Module Federation is the mechanism that makes this composition practical: it allows a JavaScript application to expose modules to other applications and to consume modules from them, sharing dependencies like Vue itself so that multiple instances do not collide .
For Vue teams, Module Federation offers a path away from the “upgrade everything at once” problem. A legacy Vue 2 dashboard can coexist with a new Vue 3 feature module, each deployed on its own schedule, as long as the shell knows how to load both. This chapter examines how Module Federation works with Vue, how Vite-based configurations differ from Webpack’s original implementation, and what architectural rules keep federated Vue applications from becoming a distributed monolith.
Key point: Module Federation does not eliminate coupling; it relocates coupling from build time to runtime. The shared configuration—especially singleton Vue instances—is where that coupling is either managed successfully or exposed as a production bug.
Why Module Federation exists
The deployment coupling problem. In a traditional Vue SPA, every change to any route requires rebuilding and redeploying the entire application. A typo fix in the footer blocks a critical feature release. Module Federation breaks this coupling by allowing each exposed module to live in its own repository and deployment pipeline . The shell application fetches the module at runtime from a URL, meaning the remote team can ship updates without coordinating with the shell team. The trade-off is that runtime failures replace build-time failures: a remote that is down or misconfigured becomes a user-facing error rather than a compilation error.
The dependency duplication problem. Naively loading two independently built Vue applications into one page produces two Vue runtimes, two reactivity systems, and two sets of component instances. Context, provide/inject, and global state break across that boundary. Module Federation’s shared configuration exists to solve this: Vue is declared as a shared singleton, and the federation runtime negotiates which copy to use at load time . Without this, federated Vue applications cannot communicate through normal Vue mechanisms.
The framework lock-in problem. Webpack 5 introduced Module Federation, which meant teams wanting to use it were tied to Webpack’s build pipeline . Vite’s ecosystem has produced several federation plugins, most notably @module-federation/vite and @originjs/vite-plugin-federation, which bring the same concepts to Vite’s ESM-based dev server and Rollup production builds . The rise of Module Federation 2.0 added a framework-agnostic runtime, a manifest format, and dynamic type hinting, making federation less dependent on a specific bundler .
The team autonomy problem. Micro-frontends are as much an organizational pattern as a technical one. When a single team owns the entire frontend, the overhead of federation rarely justifies itself. When multiple teams own distinct business domains—checkout, catalog, account—federation allows those teams to choose their own release cadence, their own testing strategy, and even their own Vue version if necessary . The cost is coordination: shared design tokens, consistent authentication context, and a contract for what each remote exposes must be governed deliberately.
a. Module Federation Configuration for Vue
A Module Federation setup has two sides. The remote application exposes modules through an entry file. The host (or shell) application declares where to find those remotes and consumes their exposed modules. Both sides declare which dependencies should be shared.
Using @module-federation/vite, a Vue remote’s configuration looks like this:
// remote-app/vite.config.ts — exposes a Vue component to other applications
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { federation } from '@module-federation/vite'
export default defineConfig({
plugins: [
vue(),
federation({
// name is the global identifier the host uses to reference this remote
name: 'remote',
// filename is the generated entry file the host downloads
filename: 'remoteEntry.js',
// exposes maps import paths to actual files in this project
exposes: {
'./ProductCard': './src/components/ProductCard.vue'
},
// shared ensures Vue is not duplicated across host and remote
shared: ['vue']
})
]
})
The host configuration declares the remote’s URL and shared dependencies. With @module-federation/vite, the remote entry can be referenced as a module URL.
// host-app/vite.config.ts — consumes the remote Vue component
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { federation } from '@module-federation/vite'
export default defineConfig({
plugins: [
vue(),
federation({
name: 'host',
// remotes maps a local alias to the remote's entry file URL
remotes: {
remote: {
type: 'module',
name: 'remote',
entry: 'http://localhost:5001/remoteEntry.js',
entryGlobalName: 'remote',
shareScope: 'default'
}
},
// Vue must be shared here as well, or the remote loads its own copy
shared: ['vue']
})
]
})
Consuming the remote component in a Vue host uses defineAsyncComponent, because the remote module is loaded over the network and cannot be resolved synchronously .
<!-- host-app/src/App.vue — loads the remote component asynchronously -->
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
// The import path matches remoteName/exposedKey from the remote's config
const ProductCard = defineAsyncComponent(
() => import('remote/ProductCard')
)
</script>
<template>
<div>
<h1>Host Application</h1>
<!-- Render the remote component; it behaves like any local component -->
<ProductCard v-if="ProductCard" />
</div>
</template>
The shared: ['vue'] shorthand is convenient but insufficient for production Vue federation. Vue must be a singleton with a requiredVersion, or host and remote will silently run different Vue versions .
// Production shared configuration — Vue must be a singleton
shared: {
vue: {
// singleton: true forces one Vue instance across all federated apps
singleton: true,
// requiredVersion makes incompatible versions fail fast rather than silently
requiredVersion: '^3.5.0'
}
}
b. Remote Modules, Routing, and Application Boundaries
Exposing a single component is the simplest case. Real micro-frontends usually expose route-level features—a complete page or section with its own internal routing, state, and API calls. This introduces boundary questions that component-level federation does not face.
A remote application built with Vue Router can expose its route configuration rather than individual components. The host imports the routes and merges them into its own router.
// remote-app/src/routes.ts — exported route definitions for host consumption
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
// The host will mount these routes under its own path prefix
path: 'products',
name: 'products',
component: () => import('./views/ProductList.vue'),
children: [
{
path: ':id',
name: 'product-detail',
component: () => import('./views/ProductDetail.vue')
}
]
}
]
export default routes
// remote-app/vite.config.ts — exposes routes, not just components
exposes: {
'./routes': './src/routes.ts',
'./ProductCard': './src/components/ProductCard.vue'
}
The host consumes the routes and adds them to its router. This pattern requires that the remote’s internal routes use relative paths (without a leading slash), so the host can mount them under any prefix.
// host-app/src/router/index.ts — merges remote routes into the host router
import { createRouter, createWebHistory } from 'vue-router'
import remoteRoutes from 'remote/routes'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('../views/Home.vue') },
// Mount remote routes under the /shop prefix
{
path: '/shop',
component: () => import('../layouts/ShopLayout.vue'),
children: remoteRoutes
}
]
})
export default router
This approach keeps the remote in control of its internal navigation while the host controls where that feature lives in the overall URL space. The critical rule is that the remote must not assume it owns the root path. A remote route with path: '/' will conflict with the host’s own root route and produce silent navigation failures .
Shared state across the boundary is another decision point. Vue’s provide/inject works across federated components only if Vue is a true singleton. If both applications run the same Vue instance, a provide in the host is visible to injected dependencies in the remote . If the singleton configuration is wrong, inject returns undefined and the remote fails in ways that are difficult to debug.
c. Operational Rules and Module Federation 2.0
Module Federation’s runtime nature means that configuration correctness is not enough. The operational model—how remotes are versioned, how failures are handled, and how the shared scope is governed—determines whether federation is sustainable.
Manifest-based loading. Module Federation 2.0 introduced a manifest format that describes a remote’s exposes, shared dependencies, and metadata as JSON . Instead of hardcoding a remoteEntry.js URL, the host fetches the manifest first, then loads the specific remote entry based on that manifest. This enables version resolution, integrity checking, and fallback URLs.
// Host consuming a manifest URL instead of a raw entry file
remotes: {
catalog: 'https://cdn.example.com/catalog/mf-manifest.json'
}
Error isolation. A remote that fails to load must not take down the host. Vue’s defineAsyncComponent accepts errorComponent and loadingComponent options, which provide a boundary around the remote.
<script setup lang="ts">
import { defineAsyncComponent, h } from 'vue'
const ProductCard = defineAsyncComponent({
loader: () => import('remote/ProductCard'),
// Show a fallback while the remote loads
loadingComponent: () => h('div', 'Loading...'),
// Show this if the remote fails to load
errorComponent: () => h('div', 'Product unavailable'),
// Wait 2 seconds before showing the loading state
delay: 200
})
</script>
Version negotiation. The shareScope and requiredVersion settings on shared dependencies determine which copy of Vue wins when multiple remotes declare different requirements. By default, the highest compatible version is used. Setting singleton: true with strictVersion: true forces a build-time error if an incompatible version is encountered, which surfaces the problem before deployment rather than at runtime .
The shell’s responsibility. The host application owns the shared context: authentication state, theme tokens, and the Vue runtime itself. Remotes should treat these as contracts, not implementation details. If a remote needs to know the current user, that state should be provided by the shell through a documented mechanism (provide/inject or a shared store), not fetched independently by each remote . This keeps the number of network requests proportional to the page, not to the number of federated features.
Type sharing. Module Federation 2.0’s dynamic type hinting generates TypeScript declarations for exposed modules, so the host gets autocomplete and type checking for remote imports without a manual publishing step . This is a significant improvement over earlier setups, where remote module types had to be maintained separately or omitted entirely.
Complete Example Session
// ============================================
// PART 1: PROJECT STRUCTURE
// ============================================
// A host Vue application consuming a remote Vue feature
//
// host-app/
// ├── src/
// │ ├── App.vue
// │ ├── router/index.ts
// │ └── main.ts
// └── vite.config.ts
//
// remote-app/
// ├── src/
// │ ├── routes.ts
// │ ├── views/ProductList.vue
// │ └── views/ProductDetail.vue
// └── vite.config.ts
// ============================================
// PART 2: REMOTE VITE CONFIGURATION
// ============================================
// remote-app/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { federation } from '@module-federation/vite'
export default defineConfig({
plugins: [
vue(),
federation({
name: 'catalog',
filename: 'remoteEntry.js',
// Expose route definitions and a reusable component
exposes: {
'./routes': './src/routes.ts',
'./ProductCard': './src/components/ProductCard.vue'
},
// Vue must be a singleton to share reactivity and provide/inject
shared: {
vue: { singleton: true, requiredVersion: '^3.5.0' }
}
})
],
// Module Federation requires ESM-compatible build target
build: { target: 'chrome89' }
})
// ============================================
// PART 3: REMOTE ROUTE DEFINITIONS
// ============================================
// remote-app/src/routes.ts — relative paths so host can mount under any prefix
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '', // Empty path renders ProductList at the mount point
name: 'product-list',
component: () => import('./views/ProductList.vue')
},
{
path: ':id',
name: 'product-detail',
component: () => import('./views/ProductDetail.vue')
}
]
export default routes
// ============================================
// PART 4: HOST VITE CONFIGURATION
// ============================================
// host-app/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { federation } from '@module-federation/vite'
export default defineConfig({
plugins: [
vue(),
federation({
name: 'shell',
remotes: {
// Alias "catalog" maps to the remote's entry file
catalog: {
type: 'module',
name: 'catalog',
entry: 'http://localhost:5001/remoteEntry.js',
entryGlobalName: 'catalog',
shareScope: 'default'
}
},
// Host must also share Vue as singleton
shared: {
vue: { singleton: true, requiredVersion: '^3.5.0' }
}
})
],
build: { target: 'chrome89' }
})
// ============================================
// PART 5: HOST ROUTER WITH REMOTE ROUTES
// ============================================
// host-app/src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
// Import remote routes — resolved through Module Federation at runtime
import remoteRoutes from 'catalog/routes'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('../views/Home.vue')
},
{
// Mount remote routes under /catalog
// The remote's empty path renders at /catalog
path: '/catalog',
children: remoteRoutes
}
]
})
export default router
// ============================================
// PART 6: HOST CONSUMING REMOTE COMPONENT
// ============================================
// host-app/src/views/Home.vue
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
// Async component with error boundary
const ProductCard = defineAsyncComponent({
loader: () => import('catalog/ProductCard'),
// Fallback UI if remote fails to load
errorComponent: () => h('div', { class: 'error' }, 'Product unavailable'),
delay: 200
})
</script>
<template>
<div>
<h1>Shell Home</h1>
<ProductCard v-if="ProductCard" />
</div>
</template>
// ============================================
// PART 7: REMOTE COMPONENT
// ============================================
// remote-app/src/components/ProductCard.vue
<script setup lang="ts">
// This component can inject dependencies provided by the host
// because Vue is a shared singleton
const props = defineProps<{ productId: string }>()
</script>
<template>
<div class="product-card">
<p>Product: {{ productId }}</p>
</div>
</template>
// ============================================
// PART 8: HOST PROVIDING SHARED CONTEXT
// ============================================
// host-app/src/App.vue — provides auth state to all federated children
<script setup lang="ts">
import { provide, ref } from 'vue'
const currentUser = ref({ id: '1', name: 'Alice' })
// Any remote component that calls inject('currentUser') receives this ref
provide('currentUser', currentUser)
</script>
<template>
<RouterView />
</template>
// ============================================
// PART 9: REMOTE INJECTING HOST CONTEXT
// ============================================
// remote-app/src/views/ProductDetail.vue
<script setup lang="ts">
import { inject } from 'vue'
// This works only if Vue is a true singleton across host and remote
const currentUser = inject<Ref<{ name: string }>>('currentUser')
</script>
<template>
<div>
<p v-if="currentUser">Viewed by: {{ currentUser.name }}</p>
</div>
</template>
// ============================================
// PART 10: PRODUCTION SHARED CONFIGURATION
// ============================================
// Both host and remote must match this configuration
shared: {
vue: {
singleton: true,
// strictVersion prevents silent version mismatches
strictVersion: true,
requiredVersion: '^3.5.0'
}
}
The ten parts trace the full federation path: the remote exposes routes and components; the host declares the remote URL and mounts the routes; shared Vue singleton enables provide/inject across the boundary; and production configuration enforces version compatibility.
Quick Reference
Federation Configuration Options
| Option | Side | Purpose |
|---|---|---|
name | Both | Global identifier for the federation container |
filename | Remote | Generated entry file name (default remoteEntry.js) |
exposes | Remote | Map of public module paths to local files |
remotes | Host | Map of aliases to remote entry URLs |
shared | Both | Dependencies negotiated at runtime |
singleton | Both | Force one instance across all federated apps |
Shared Vue Configuration
| Setting | Recommended | Effect |
|---|---|---|
singleton | true | One Vue runtime; provide/inject works |
requiredVersion | Project’s Vue range | Fails if remote’s Vue is incompatible |
strictVersion | true in production | Build-time error instead of silent mismatch |
import | false for optional deps | Exclude dependency from bundle if unused |
Remote Route Rules
| Rule | Reason |
|---|---|
Use relative paths (no leading /) | Host controls mount prefix |
Empty path: '' for index route | Renders at mount point |
| Export routes as a typed array | Host router merges them directly |
Avoid root path: '/' | Conflicts with host root route |
Failure Handling
| Scenario | Mitigation |
|---|---|
| Remote URL is down | errorComponent in defineAsyncComponent |
| Remote loads slowly | loadingComponent with delay |
| Version mismatch | strictVersion: true with requiredVersion |
| Remote route conflicts | Enforce relative-path convention |
Best Practices
✅ Do This:
// Declare Vue as singleton with requiredVersion on both sides
vue: { singleton: true, requiredVersion: '^3.5.0' } // ✅
<!-- Wrap remote imports in defineAsyncComponent with error handling -->
defineAsyncComponent({ loader: () => import('remote/App') }) // ✅
// Use relative paths in remote route definitions
path: 'products' // ✅
// Provide shared context from the shell via provide/inject
provide('auth', currentUser) // ✅
// Expose routes as data, not as a configured router instance
export default routes // ✅
// Set build target to chrome89 or later for federation
build: { target: 'chrome89' } // ✅
❌ Don’t Do This:
// Share Vue without singleton configuration
shared: ['vue'] // ❌
<!-- Import remote modules synchronously in setup -->
import RemoteApp from 'remote/App' // ❌
// Use absolute paths in remote routes
path: '/products' // ❌
// Fetch user state independently in each remote
const user = await fetchUser() // ❌
// Export a configured router instance instead of route array
export default createRouter({ routes }) // ❌
// Target older browsers without the top-level await plugin
build: { target: 'es2015' } // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
inject returns undefined in remote | Vue not a singleton | Set singleton: true on both sides |
| Remote component renders blank | Missing defineAsyncComponent | Wrap remote import in defineAsyncComponent |
| Two Vue instances in devtools | shared omitted on one side | Both host and remote must declare shared |
| Remote route not matched | Absolute path in remote config | Remove leading slash from remote path |
| Build fails on old browser target | Top-level await unsupported | Set build.target: 'chrome89' or add plugin |
| Remote updates not appearing | Browser caching remoteEntry.js | Add cache-busting to remote URL or use manifest |
Real-World Examples
1. Exposing a Vue Component
exposes: {
'./Button': './src/components/Button.vue'
}
2. Host Loading Remote Route Array
import remoteRoutes from 'remote/routes'
router.addRoute({ path: '/feature', children: remoteRoutes })
3. Singleton Vue with Version Check
shared: {
vue: { singleton: true, requiredVersion: '^3.5.0' }
}
4. Error Boundary for Remote
const Remote = defineAsyncComponent({
loader: () => import('remote/App'),
errorComponent: () => h('div', 'Unavailable')
})
5. Remote Injecting Host Auth
const user = inject('currentUser')
// Works only with singleton Vue
6. Manifest-Based Remote Loading
remotes: {
catalog: 'https://cdn.example.com/catalog/mf-manifest.json'
}
7. Vite Remote Build Target
build: { target: 'chrome89' }
8. Sharing a State Library
shared: {
vue: { singleton: true },
pinia: { singleton: true }
}
9. Exposing a Composable
exposes: {
'./useCart': './src/composables/useCart.ts'
}
10. Host Router Mounting Remote
{
path: '/catalog',
children: remoteCatalogRoutes
}
Visual
Host and Remote Architecture
┌──────────────────────────────────────────────────────────────┐
│ HOST APPLICATION │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Local routes │ │ Remote routes │ │
│ │ / │ │ /catalog/* │ │
│ │ /about │ │ │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Local Vue │ │ Remote Vue │ │
│ │ components │ │ components │ │
│ └─────────────────┘ └────────┬────────┘ │
│ │ │
│ │ runtime fetch │
│ ▼ │
│ ┌─────────────────┐ │
│ │ remoteEntry.js │ │
│ │ (remote server) │ │
│ └─────────────────┘ │
│ │
│ Vue is shared as a singleton across both boundaries. │
│ provide/inject works because there is one Vue instance. │
│ │
└──────────────────────────────────────────────────────────────┘
Shared Dependency Resolution
┌──────────────────────────────────────────────────────────────┐
│ FEDERATION RUNTIME SHARED SCOPE │
│ │
│ Host declares: Remote declares: │
│ vue: { singleton: true, vue: { singleton: true, │
│ requiredVersion: requiredVersion: │
│ '^3.5.0' } '^3.5.0' } │
│ │ │ │
│ └──────────────┬───────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Runtime negotiates │ │
│ │ one Vue instance │ │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Single Vue runtime │ │
│ │ shared by both apps │ │
│ └─────────────────────┘ │
│ │
│ Without singleton, each app loads its own Vue copy. │
│ provide/inject across the boundary returns undefined. │
│ │
└──────────────────────────────────────────────────────────────┘
Remote Route Mounting
┌──────────────────────────────────────────────────────────────┐
│ HOST ROUTER │
│ │
│ routes: [ │
│ { path: '/', component: Home }, │
│ { path: '/catalog', │
│ children: [ │
│ { path: '', component: ProductList }, ← /catalog│
│ { path: ':id', component: ProductDetail } ← /catalog/1│
│ ] │
│ } │
│ ] │
│ │
│ Remote routes use RELATIVE paths. │
│ The host prefixes them with /catalog. │
│ If remote used path: '/', it would match the host's root. │
│ │
└──────────────────────────────────────────────────────────────┘
Failure Isolation Flow
┌──────────────────────────────────────────────────────────────┐
│ HOST RENDERS PAGE │
│ │
│ ┌─────────────────┐ │
│ │ defineAsync │ │
│ │ Component │ │
│ │ starts loading │ │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Remote loads? │────►│ YES: Render │ │
│ │ │ │ remote component│ │
│ └────────┬────────┘ └─────────────────┘ │
│ │ NO (timeout, 404, error) │
│ ▼ │
│ ┌─────────────────┐ │
│ │ errorComponent │ │
│ │ renders │ │
│ └─────────────────┘ │
│ │
│ The host page remains functional. │
│ Only the remote feature is unavailable. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Remote | Application that exposes modules via exposes |
| Host | Application that consumes remotes via remotes |
remoteEntry.js | Generated entry file the host downloads |
shared | Dependencies negotiated at runtime |
singleton: true | One instance across all federated apps |
| Vue singleton | Required for provide/inject across boundaries |
defineAsyncComponent | Wraps remote imports with loading/error states |
| Relative route paths | Allow host to control mount prefix |
| Module Federation 2.0 | Adds manifest, runtime, dynamic types |
@module-federation/vite | Vite plugin for federation with Vue |
Key takeaways:
- Vue must be a shared singleton. Without
singleton: trueand matchingrequiredVersion, the host and remote each load their own Vue runtime. Reactivity, provide/inject, and global state break across that boundary, and the failure mode is often silent. - Remote routes use relative paths. The host owns the URL structure. A remote that declares
path: '/'will conflict with the host’s root route. Exporting route arrays instead of configured router instances keeps the remote reusable under any prefix. - Wrap remote imports in
defineAsyncComponent. Remote modules are network resources. They can be slow, unavailable, or broken. The host must provide loading and error states so that one failed remote does not blank the entire page. - The shell provides shared context. Authentication, theme, and other cross-cutting state should flow from host to remote through
provide/injector a shared store. Each remote fetching user data independently multiplies network requests and creates inconsistency. - Module Federation 2.0 improves the operational model. Manifest-based loading, dynamic type hinting, and a framework-agnostic runtime address many of the rough edges in Webpack 5’s original implementation. The manifest format enables version resolution and fallback URLs that raw
remoteEntry.jsURLs cannot express.
Remember: Module Federation moves the integration point from build time to runtime. That shift enables independent deployment, but it also means failures happen in the browser rather than in CI. The configuration that matters most is not exposes or remotes—it is shared. Vue as a singleton is the foundation. Without it, federated Vue applications are not really one application; they are two applications that happen to share a page. With it, they share reactivity, context, and the component model itself, which is what makes the composition feel native rather than bolted together.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!