DEV Community

Cover image for Building a Microfrontend Architecture with Vue 3, Vite, Single-SPA
Sebastian Wrzałek
Sebastian Wrzałek

Posted on

Building a Microfrontend Architecture with Vue 3, Vite, Single-SPA

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

"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.

enter image description here

Single-SPA Components:

  1. 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
  2. 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
Enter fullscreen mode Exit fullscreen mode

After creating the root application, install the necessary dependencies:

npm install --save single-spa-vue vite-plugin-single-spa single-spa
Enter fullscreen mode Exit fullscreen mode

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))
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Install additional dependencies for this new app:

npm install --save single-spa-vue vite-plugin-single-spa
Enter fullscreen mode Exit fullscreen mode

Below is the directory structure so far:

single-spa-demo
├── root
│   ├── src
│   ├── vite.config.ts
│   └── package.json
└── app
    ├── src
    ├── vite.config.ts
    └── package.json
Enter fullscreen mode Exit fullscreen mode

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
  }
})
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

root/src/importMap.json

{
  "imports": {
    "@howlydev/app": "http://localhost:4101/main.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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.

enter image description here

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.

enter image description here

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

  1. The root application doesn’t “know” about /app.
  2. 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"
Enter fullscreen mode Exit fullscreen mode

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();  
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
### 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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

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 calling registerApplication.

Collapse
 
swrzalek profile image
Sebastian Wrzałek • Edited

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. 🙌

Collapse
 
johnnysench profile image
Eugene • Edited

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)

Collapse
 
swrzalek profile image
Sebastian Wrzałek

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