Single-SPA – Implementation with Example

Single-SPA - Implementation with Example

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.

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

Let’s walk through the process of setting up a new Single-SPA application:

  1. Create Root Config: First, install the Single-SPA CLI:
    npm install --global single-spa-cli
    Then create a new root config:
    single-spa create
  2. 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
  3. Create Micro Frontends: For each micro frontend, create a separate application that exports the required lifecycle methods:
    • bootstrap
    • mount
    • unmount
  4. 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!

Scroll to Top