Microfrontends with Vue 3, Vite, and Single-SPA
Microfrontends have become a popular approach for scaling frontend applications, especially in environments where multiple teams work on various parts of the app independently. In this guide, we'll explore how to build a simple microfrontend application using Vue 3, Vite, Single-SPA, and vite-plugin-single-spa.
Table of Contents
- Introduction to Microfrontends
- Understanding Single-SPA Framework
- Demo Project
- Prerequisites
- Creating the Secondary (Microfrontend) Application
- Configuring the Secondary App
- Handling Different Bundle Locations
- Registering the Microfrontend in the Root Project
- Testing the Setup
- The Problem
- The Second Problem
- Final Words
Introduction to Microfrontends
"The idea behind Micro Frontends is to think about a website or web app as a composition of features which are owned by independent teams. Each team has a distinct area of business or mission it cares about and specializes in. A team is cross-functional and develops its features end-to-end, from database to user interface."
Microfrontends are an architectural approach that extends the concept of microservices to the frontend. Instead of building a single monolithic frontend application, the app is divided into smaller, manageable pieces called microfrontends. Each microfrontend is a self-contained application with its own codebase, framework, and deployment pipeline.
Benefits of Microfrontends:
- Scalability – Multiple teams can work independently on different features.
- Flexibility – Each microfrontend can use its own tech stack.
- Resilience – Failures in one microfrontend don’t affect others.
Understanding Single-SPA Framework
Single-SPA is a framework that allows multiple microfrontends, potentially using different frameworks (e.g., Vue, React), to coexist and operate within the same application. It manages the lifecycle of each microfrontend, enabling them to load and unload dynamically based on routing configurations.
Single-SPA Components:
-
Root Configuration – Responsible for rendering the HTML page and registering applications. Each application is registered with:
- A name
- A function to load the application’s code
- A function to determine when the application is active or inactive
- Applications – Microfrontends functioning as SPAs packaged into modules. Each application must implement methods to bootstrap, mount, and unmount itself from the DOM.
Core Concepts of Single-SPA:
- Application Registrations – Register each microfrontend as an application.
- Lifecycle Methods – Each microfrontend has lifecycle methods (mount, unmount, etc.).
- Routing – Single-SPA can load microfrontends based on URL paths.
Example:
A React or Vue SPA can be registered as an application. When active, it listens to URL routing events and renders content on the DOM; when inactive, it stops listening to routing events and is fully removed from the DOM.
Demo Project
In this article, we’ll build both the root application—responsible for orchestrating the mounting and unmounting of microfrontends—and a sample microfrontend for demonstration. Although the official recommendation discourages using a framework in the root configuration, this project uses a Vue application at the root to provide a consistent layout, global navigation, and cohesive styling across all microfrontends.
Prerequisites
Let’s create our root application. This project uses Vue as its framework:
npm create vue@latest
✔ Project name: … root
✔ Add TypeScript? … Yes
✔ Add JSX Support? … No
✔ Add Vue Router for Single Page Application development? … Yes
✔ Add Pinia for state management? … No
✔ Add Vitest for Unit Testing? … No
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? … Yes
✔ Add Prettier for code formatting? … Yes
✔ Add Vue DevTools 7 extension for debugging? (experimental) … No
cd root
npm install
npm run format
npm run dev
After creating the root application, install the necessary dependencies:
npm install --save single-spa-vue vite-plugin-single-spa single-spa
Next, integrate vite-plugin-single-spa
within your vite.config.ts
:
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vitePluginSingleSpa from 'vite-plugin-single-spa';
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vitePluginSingleSpa({
type: 'root',
imo: '3.1.1'
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
Creating the Secondary (Microfrontend) Application
Before we configure the root application to load a microfrontend, let’s create another Vue app:
npm create vue@latest
✔ Project name: … app
✔ Add TypeScript? … Yes
✔ Add JSX Support? … No
✔ Add Vue Router for Single Page Application development? … Yes
✔ Add Pinia for state management? … No
✔ Add Vitest for Unit Testing? … No
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? … Yes
✔ Add Prettier for code formatting? … Yes
✔ Add Vue DevTools 7 extension for debugging? (experimental) … No
cd app
Install additional dependencies for this new app:
npm install --save single-spa-vue vite-plugin-single-spa
Below is the directory structure so far:
single-spa-demo
├── root
│ ├── src
│ ├── vite.config.ts
│ └── package.json
└── app
├── src
├── vite.config.ts
└── package.json
Configuring the Secondary App
We must configure the Vite Single-SPA plugin so the Vue application outputs a microfrontend:
// ### app/vite.config.ts ###
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vitePluginSingleSpa from 'vite-plugin-single-spa'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
vitePluginSingleSpa({
type: 'mife',
serverPort: 4101,
spaEntryPoints: 'src/main.ts',
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
// Ensure the dev server uses the same port as set in the plugin
server: {
port: 4101
}
})
Here, vitePluginSingleSpa({ type: 'mife', serverPort: 4101 })
configures the plugin, identifying this build as a microfrontend and setting the development server to port 4101.
Main File Modifications
The microfrontend must know how to bootstrap, mount, and unmount itself from the DOM. Update main.ts
accordingly:
// ### app/main.ts ###
import App from './App.vue'
import './assets/main.css'
import singleSpaVue from 'single-spa-vue'
import { createApp, h } from 'vue'
import router from './router'
const vueLifecycles = singleSpaVue({
createApp,
appOptions: {
render() {
return h(App, {
props: {
name: this.name,
},
});
},
},
handleInstance: (app) => {
app.use(router);
},
});
const mountVue = () => {
const app = createApp(App)
app.use(router)
app.mount('#app')
}
// Only mount the app in standard SPA mode during development
if (import.meta.env.MODE === 'development') {
mountVue();
}
export const bootstrap = vueLifecycles.bootstrap;
export const mount = vueLifecycles.mount;
export const unmount = vueLifecycles.unmount;
Because Single-SPA handles mounting and unmounting, we cannot mount the application in production in the usual way. However, we still want to support standalone development, so we only mount if import.meta.env.MODE
is development.
Below is an example of how to differentiate development modes in your package.json
:
{
"scripts": {
"dev": "vite --mode development",
"dev:sspa": "vite --mode staging"
}
}
With these changes, the microfrontend is ready to be consumed by the root project.
Handling Different Bundle Locations
One challenge is that the microfrontend’s entry file (main.ts
) is served at different URLs in development vs. production:
-
Development:
http://localhost:4101/src/main.ts
-
Build:
http://localhost:4101/main.js
We will address this discrepancy using native import maps. Within the root project, create two files for mapping these paths:
root/src/importMap.dev.json
{
"imports": {
"@howlydev/app": "http://localhost:4101/src/main.ts"
}
}
root/src/importMap.json
{
"imports": {
"@howlydev/app": "http://localhost:4101/main.js"
}
}
These files instruct the root application to resolve the microfrontend’s path differently based on environment.
Registering the Microfrontend in the Root Project
Create a file named single-spa.setup.ts
in your root app:
// ### root/single-spa.setup.ts ###
import { registerApplication, start } from "single-spa"
const apps = {
"app": "@howlydev/app"
}
export function registerSpas() {
for (const [route, moduleName] of Object.entries(apps)) {
registerApplication({
name: route,
app: () => import(/* @vite-ignore */ moduleName),
activeWhen: `/${route}`,
})
}
runSpas();
}
export function runSpas() {
start();
}
- registerApplication: Tells Single-SPA which application to load and on which route path.
- start(): Initializes Single-SPA, enabling the lifecycle of registered applications.
Testing the Setup
Run both the root and microfrontend applications in separate terminals:
# In the root folder
npm run dev
# In the app folder
npm run dev
Open the URL for your root application (e.g., http://localhost:5173/
). The microfrontend should be dynamically loaded and displayed when its route is accessed.
The Problem
When you navigate to the route that initializes the microfrontend, you may notice unexpected behavior: because both the root application and the microfrontend use Vue, there are two Vue instances running. Additionally, the microfrontend is not rendered within the router-view
controlled by the root’s Vue Router.
Inspecting the DOM reveals that both the root and microfrontend instances are active, but the microfrontend content is appended directly to the body (or another top-level element) rather than where the root router expects it.
To fix this, you can leverage Vue’s Teleport
feature to “teleport” the microfrontend’s content into a specified container within the root application:
<!-- ### app/App.vue ### -->
<template>
<Teleport to=".container" :disabled="isTeleportDisabled">
<div style="background-color: lightcoral">
<h1>Hello From the Microfrontend</h1>
<router-view />
<ul>
<li>
<router-link to="/foo">Go to Foo</router-link>
</li>
<li>
<router-link to="/bar">Go to Bar</router-link>
</li>
</ul>
</div>
</Teleport>
</template>
<script setup lang="ts">
const isTeleportDisabled = import.meta.env.MODE === 'development'
</script>
Meanwhile, in your root application, ensure there is a .container
element available to receive this teleported content:
### root/App.vue ###
<template>
<main>
<div class="container">
<router-view />
</div>
</main>
</template>
Here, the microfrontend’s content is rendered where you want it, and the teleport is disabled during standalone (development) mode so you can still see the microfrontend by itself.
The Second Problem
A common requirement is that each microfrontend can handle its own internal routing. In this example:
- The root application doesn’t “know” about
/app
. - The microfrontend expects its base route to be
/
.
When you visit "/app"
, you might see a warning in the console:
[Vue Router warn]: No match found for location with path "/app"
Though you can’t fully suppress these warnings (see this Vue Router issue), you can enable routing in the microfrontend by changing the base path, we will go even further and pass it from root application:
### root/single-spa.setup.ts ###
export const apps = {
app : "@howlydev/app"
}
export function registerSpas() {
for (const [route, moduleName] of Object.entries(apps)) {
registerApplication({
name: route,
app: () => import(/* @vite-ignore */ moduleName),
activeWhen: `/${route}`,
customProps: {
basePath: `/${route}` /* <-- this is new */
}
});
}
runSpas();
}
export function runSpas() {
start();
}
Next we will pass our basePath
to the router.
### app/src/main.ts ###
import App from './App.vue'
import './assets/main.css'
import singleSpaVue from 'single-spa-vue'
import {createApp, h} from "vue";
import router from './router'
type Props = {
name: string,
basePath: string,
}
const vueLifecycles = singleSpaVue({
createApp,
appOptions: {
render() {
const { name, basePath } = this as unknown as Props;
return h(App, {
name: name,
basePath: basePath,
});
},
},
handleInstance: (app, props: Props) => {
app.use(router(props.basePath));
}
});
const mountVue = () => {
const app = createApp(App)
app.use(router())
app.mount('#app')
}
if (import.meta.env.MODE === 'development') {
mountVue();
}
export const bootstrap = vueLifecycles.bootstrap;
export const mount = vueLifecycles.mount;
export const unmount = vueLifecycles.unmount;
### app/src/router/index.ts ###
import { createRouter, createWebHistory } from 'vue-router'
import FooView from "@/views/FooView.vue";
import BarView from "@/views/BarView.vue";
const router = (basePath = '/app') => createRouter({
history: createWebHistory(basePath),
routes: [
{
path: "/foo",
name: "foo",
component : FooView,
},
{
path: "/bar",
name: "bar",
component : BarView
}
],
})
export default router
Our router is now a function that accepts basePath
as an argument and returns Router instance that can be used by a Vue app.
Final Words
Microfrontends can be highly beneficial for large-scale applications, but they’re not always straightforward to implement. As shown here, multiple Vue instances and routing complexities can create unexpected issues. Despite these trade-offs (and alternatives such as vite-plugin-federation), microfrontend architecture is worth knowing about. You might ask: is it solving a real problem?
It depends.
- If you need to build a simple Todo app, it’s probably overkill.
- If you’re building a Cloud ERP, it might be worth exploring.
I’ll quote Uncle Bob here:
You should be able to structure your code in a clean, modular fashion within a monolith before considering distribution. If you cannot form clear boundaries in a single-process system, you are unlikely to succeed by splitting it into microservices.
The full GitHub repository can be found here:
https://github.com/swrzalek/vue3-vite-sspa-demo/tree/main
If you’d like to see a more complex, real-world microfrontend example, let me know in the comments!
If you found this article helpful, please leave a like! ❤️
Top comments (4)
Great job. If I were to point out a single thing to improve, it would be to pass the base path of the MFE from the root to the child MFE. This allows you to mount the MFE anywhere within the root application by just changing the path in the root application. You can do this by passing
customProps
when callingregisterApplication
.Thanks for the suggestion. I've applied the changes you mentioned, and it works even better. I appreciate all the effort you've put into developing this plugin. 🙌
Good day Sebastian! Thanks for your implementation, but how can I make your version work in build mode? And yes, I would really like to see the implementation of a complex microfrontend)
Hello @johnnysench. I made a little fix for the build, you need to specify
spaEntryPoints
for the build and also set the correct import path ex. "localhost:4101/main.js". commit