Single-SPA has emerged as a leading solution for implementing micro frontends, allowing development teams to build large-scale applications with greater autonomy and flexibility. In this article, we’ll explore Single-SPA in depth, provide practical implementation examples, and compare it with competing solutions.
What is Single-SPA?
Single-SPA (Single Single-Page Application) is a JavaScript framework for bringing together multiple JavaScript micro frontends in a frontend application. It provides the core infrastructure needed to:
- Register and load applications on-demand
- Orchestrate the lifecycle of each application
- Handle route-based activation
- Facilitate communication between applications
- Support multiple frameworks simultaneously (React, Angular, Vue, etc.)
Setting Up Single-SPA From Scratch
Let’s walk through the process of setting up a new Single-SPA application:
- Create Root Config: First, install the Single-SPA CLI:
npm install --global single-spa-cli
Then create a new root config:single-spa create
- Register Applications: The root config (as shown in the example above) registers each application with:
- A unique name
- A loading function
- Activity rules (when to show it)
- DOM element mounting point
- Create Micro Frontends: For each micro frontend, create a separate application that exports the required lifecycle methods:
bootstrap
mount
unmount
- Configure Shared Dependencies: Use import maps or SystemJS to share common dependencies across applications.
// PROJECT STRUCTURE
//
// root/
// ├── root-config/ # Root configuration application
// ├── nav/ # Navigation micro frontend (React)
// ├── products/ # Products micro frontend (Vue)
// └── account/ # Account micro frontend (Angular)
// ----- ROOT CONFIG APPLICATION -----
// root-config/src/index.ejs (HTML template)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Single-SPA E-commerce Example</title>
<!--
Import maps allow you to map module names to URLs
This is crucial for sharing dependencies across micro frontends
-->
<script type="systemjs-importmap">
{
"imports": {
"single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js",
"react": "https://cdn.jsdelivr.net/npm/react@17.0.2/umd/react.production.min.js",
"react-dom": "https://cdn.jsdelivr.net/npm/react-dom@17.0.2/umd/react-dom.production.min.js",
"vue": "https://cdn.jsdelivr.net/npm/vue@3.0.11/dist/vue.global.js",
"@ecommerce/root-config": "//localhost:9000/ecommerce-root-config.js",
"@ecommerce/nav": "//localhost:9001/ecommerce-nav.js",
"@ecommerce/products": "//localhost:9002/ecommerce-products.js",
"@ecommerce/account": "//localhost:9003/ecommerce-account.js"
}
}
</script>
<!-- Load SystemJS to handle module loading -->
<script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/system.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/extras/amd.min.js"></script>
</head>
<body>
<!-- Elements where micro frontends will be mounted -->
<div id="nav-container"></div>
<main id="single-spa-application"></main>
<!-- Load and start the root config -->
<script>
System.import('@ecommerce/root-config');
</script>
</body>
</html>
// root-config/src/ecommerce-root-config.js
import { registerApplication, start } from 'single-spa';
// Register the nav application - always active
registerApplication({
name: '@ecommerce/nav',
app: () => System.import('@ecommerce/nav'),
activeWhen: ['/'],
domElement: document.getElementById('nav-container')
});
// Register the products application - active on product routes
registerApplication({
name: '@ecommerce/products',
app: () => System.import('@ecommerce/products'),
activeWhen: ['/products'],
domElement: document.getElementById('single-spa-application')
});
// Register the account application - active on account routes
registerApplication({
name: '@ecommerce/account',
app: () => System.import('@ecommerce/account'),
activeWhen: ['/account'],
domElement: document.getElementById('single-spa-application')
});
// Start the single-spa orchestrator
start();
// root-config/webpack.config.js
const { merge } = require("webpack-merge");
const singleSpaDefaults = require("webpack-config-single-spa");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = (webpackConfigEnv, argv) => {
const orgName = "ecommerce";
const defaultConfig = singleSpaDefaults({
orgName,
projectName: "root-config",
webpackConfigEnv,
argv,
disableHtmlGeneration: true,
});
return merge(defaultConfig, {
plugins: [
new HtmlWebpackPlugin({
inject: false,
template: "src/index.ejs",
templateParameters: {
isLocal: webpackConfigEnv && webpackConfigEnv.isLocal,
orgName,
},
}),
],
});
};
// ----- REACT NAVIGATION MICRO FRONTEND -----
// nav/src/ecommerce-nav.js
import React from 'react';
import ReactDOM from 'react-dom';
import singleSpaReact from 'single-spa-react';
import Navbar from './components/Navbar';
// Root component
const App = () => {
return <Navbar />;
};
// Create single-spa lifecycle functions
const lifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: App,
errorBoundary(err, info, props) {
return <div>Navigation Error: {err.message}</div>;
},
});
// Export the lifecycle functions
export const { bootstrap, mount, unmount } = lifecycles;
// nav/src/components/Navbar.js
import React from 'react';
import { navigateToUrl } from 'single-spa';
const Navbar = () => {
return (
<nav className="navbar">
<div className="logo">E-Shop</div>
<ul className="nav-links">
<li>
<a href="/" onClick={navigateToUrl}>Home</a>
</li>
<li>
<a href="/products" onClick={navigateToUrl}>Products</a>
</li>
<li>
<a href="/account" onClick={navigateToUrl}>My Account</a>
</li>
<li>
<a href="/cart" onClick={navigateToUrl}>Cart (0)</a>
</li>
</ul>
</nav>
);
};
export default Navbar;
// ----- VUE PRODUCTS MICRO FRONTEND -----
// products/src/ecommerce-products.js
import { h, createApp } from 'vue';
import singleSpaVue from 'single-spa-vue';
import App from './App.vue';
// Create single-spa lifecycle functions
const vueLifecycles = singleSpaVue({
createApp,
appOptions: {
render() {
return h(App, {
props: {
// single-spa props are available on the root component
name: this.name,
mountParcel: this.mountParcel,
singleSpa: this.singleSpa,
},
});
},
},
});
// Export the lifecycle functions
export const { bootstrap, mount, unmount } = vueLifecycles;
// products/src/App.vue
<template>
<div class="products-container">
<h1>Products</h1>
<div class="products-grid">
<div v-for="product in products" :key="product.id" class="product-card">
<img :src="product.image" :alt="product.name" />
<h3>{{ product.name }}</h3>
<p>${{ product.price.toFixed(2) }}</p>
<button @click="addToCart(product)">Add to Cart</button>
</div>
</div>
</div>
</template>
<script>
import { defineComponent } from 'vue';
export default defineComponent({
name: 'ProductsApp',
data() {
return {
products: [
{ id: 1, name: 'Wireless Earbuds', price: 99.99, image: 'placeholder.jpg' },
{ id: 2, name: 'Smart Watch', price: 199.99, image: 'placeholder.jpg' },
{ id: 3, name: 'Bluetooth Speaker', price: 79.99, image: 'placeholder.jpg' },
// More products...
]
};
},
methods: {
addToCart(product) {
// Dispatch custom event for cross-micro frontend communication
window.dispatchEvent(
new CustomEvent('@ecommerce/add-to-cart', {
detail: { product }
})
);
}
}
});
</script>
// ----- ANGULAR ACCOUNT MICRO FRONTEND -----
// account/src/main.single-spa.ts
import { enableProdMode, NgZone } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { Router, NavigationStart } from '@angular/router';
import { singleSpaAngular, getSingleSpaExtraProviders } from 'single-spa-angular';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
// Create single-spa lifecycle functions
const lifecycles = singleSpaAngular({
bootstrapFunction: singleSpaProps => {
return platformBrowserDynamic(getSingleSpaExtraProviders()).bootstrapModule(AppModule);
},
template: '<app-root />',
Router,
NavigationStart,
NgZone,
});
// Export the lifecycle functions
export const { bootstrap, mount, unmount } = lifecycles;
// account/src/app/app.component.ts
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-root',
template: `
<div class="account-container">
<h1>My Account</h1>
<div *ngIf="!isLoggedIn" class="login-form">
<h2>Login</h2>
<form (submit)="login($event)">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">Login</button>
</form>
</div>
<div *ngIf="isLoggedIn" class="account-details">
<h2>Welcome, {{user.name}}!</h2>
<p>Email: {{user.email}}</p>
<button (click)="logout()">Logout</button>
</div>
</div>
`,
})
export class AppComponent implements OnInit {
isLoggedIn = false;
user = { name: '', email: '' };
ngOnInit() {
// Check if user is logged in
const storedUser = localStorage.getItem('user');
if (storedUser) {
this.user = JSON.parse(storedUser);
this.isLoggedIn = true;
}
}
login(event: Event) {
event.preventDefault();
// For demo purposes, simulate login
this.user = { name: 'John Doe', email: 'john@example.com' };
localStorage.setItem('user', JSON.stringify(this.user));
this.isLoggedIn = true;
// Dispatch login event for other micro frontends
window.dispatchEvent(
new CustomEvent('@ecommerce/user-login', {
detail: { user: this.user }
})
);
}
logout() {
localStorage.removeItem('user');
this.isLoggedIn = false;
// Dispatch logout event
window.dispatchEvent(
new CustomEvent('@ecommerce/user-logout')
);
}
}
// ----- CROSS-MICRO FRONTEND COMMUNICATION -----
// Shared utility (could be in a separate utility package)
// utils/event-bus.js
export class EventBus {
static events = {};
static on(event, callback) {
if (!EventBus.events[event]) {
EventBus.events[event] = [];
}
EventBus.events[event].push(callback);
// Return unsubscribe function
return () => {
EventBus.events[event] = EventBus.events[event].filter(cb => cb !== callback);
};
}
static emit(event, data) {
if (EventBus.events[event]) {
EventBus.events[event].forEach(callback => callback(data));
}
}
}
// Usage in nav component to listen for cart updates
import { EventBus } from '@ecommerce/utils';
// Listen for cart updates
window.addEventListener('@ecommerce/add-to-cart', (event) => {
// Update cart count
const cartCount = parseInt(localStorage.getItem('cartCount') || '0') + 1;
localStorage.setItem('cartCount', cartCount.toString());
// Update UI
document.querySelector('.cart-count').textContent = cartCount;
});
At 7Shades Digital, we specialised in creating strategies that help businesses excel in the digital world. If you’re ready to take your website to the next level, contact us today!