Single-SPA Implementation for Microfrontends
Single-SPA is a microfrontend orchestrator. It manages the lifecycle of applications: when to mount, when to unmount, how to switch between them without page reload. Unlike Module Federation, Single-SPA is framework-agnostic — one microfrontend can be React, another Vue, third Angular.
Each application exports three functions: bootstrap, mount, unmount. Single-SPA calls them on schedule based on URL.
What's included in the work
Setting up root config (orchestrator), registering microfrontend applications, adapters for React/Vue/Angular, parcel components (not bound to routes), import map for version management, data exchange, CI/CD.
Architecture
root-config → single-spa orchestrator, import map
├── @company/navbar → navigation (parcel, always active)
├── @company/catalog → /products/* (React)
├── @company/checkout → /checkout/* (React)
├── @company/account → /account/* (Vue)
└── @company/legacy → /legacy/* (Angular, old code)
Root-config installation
npx create-single-spa --moduleType root-config
# or manually
npm install single-spa
root-config — application registration
// src/index.ts (root-config)
import { registerApplication, start } from 'single-spa'
registerApplication({
name: '@company/navbar',
app: () => System.import('@company/navbar'),
activeWhen: () => true, // always active
customProps: {
domElement: document.getElementById('navbar-container'),
},
})
registerApplication({
name: '@company/catalog',
app: () => System.import('@company/catalog'),
activeWhen: (location) => location.pathname.startsWith('/products'),
})
registerApplication({
name: '@company/checkout',
app: () => System.import('@company/checkout'),
activeWhen: (location) => location.pathname.startsWith('/checkout'),
})
registerApplication({
name: '@company/account',
app: () => System.import('@company/account'),
activeWhen: ['/account'],
})
start({
urlRerouteOnly: true, // do not call remount on hash-change
})
index.html with import map
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="importmap-type" content="systemjs-importmap" />
<script type="systemjs-importmap">
{
"imports": {
"single-spa": "https://cdn.jsdelivr.net/npm/single-spa@6/lib/es2015/esm/single-spa.min.js",
"react": "https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js",
"react-dom": "https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js",
"@company/navbar": "https://cdn.example.com/navbar/latest/company-navbar.js",
"@company/catalog": "https://cdn.example.com/catalog/latest/company-catalog.js",
"@company/checkout": "https://cdn.example.com/checkout/latest/company-checkout.js",
"@company/account": "https://cdn.example.com/account/latest/company-account.js"
}
}
</script>
<script src="https://cdn.jsdelivr.net/npm/systemjs@6/dist/extras/amd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/systemjs@6/dist/system.min.js"></script>
</head>
<body>
<div id="navbar-container"></div>
<div id="single-spa-application:@company/catalog"></div>
<div id="single-spa-application:@company/checkout"></div>
<div id="single-spa-application:@company/account"></div>
<script src="./src/index.js"></script>
</body>
</html>
React microfrontend
npx create-single-spa --moduleType app-parcel --framework react
// apps/catalog/src/index.tsx
import React from 'react'
import { createRoot, Root } from 'react-dom/client'
import singleSpaReact from 'single-spa-react'
import App from './App'
const lifecycles = singleSpaReact({
React,
ReactDOM: { createRoot: (el: Element) => createRoot(el) } as unknown,
rootComponent: App,
errorBoundary(err, info, props) {
return <div>Catalog app crashed: {err.message}</div>
},
})
export const { bootstrap, mount, unmount } = lifecycles
// apps/catalog/src/App.tsx
import React from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
// Single-SPA passes customProps — use to get domElement, eventBus, etc.
interface CatalogProps {
eventBus?: EventBus
basePath?: string
}
export default function App({ eventBus, basePath = '/products' }: CatalogProps) {
return (
<BrowserRouter basename={basePath}>
<Routes>
<Route path="/" element={<ProductListPage />} />
<Route path="/:id" element={<ProductDetailPage />} />
<Route path="/category/:slug" element={<CategoryPage />} />
</Routes>
</BrowserRouter>
)
}
Vue microfrontend
npx create-single-spa --moduleType app-parcel --framework vue
npm install single-spa-vue
// apps/account/src/main.ts
import { createApp, App as VueApp, h } from 'vue'
import singleSpaVue from 'single-spa-vue'
import App from './App.vue'
import router from './router'
let app: VueApp | null = null
const vueLifecycles = singleSpaVue({
createApp,
appOptions: {
render() {
return h(App, {
// single-spa props
...this.$props,
})
},
},
handleInstance(appInstance) {
appInstance.use(router)
app = appInstance
},
})
export const bootstrap = vueLifecycles.bootstrap
export const mount = vueLifecycles.mount
export const unmount = vueLifecycles.unmount
Parcels — components without route binding
Parcel is a microfrontend without a route condition. Used for widgets (cart, notifications, chat):
// apps/catalog/src/components/MiniCart.tsx
import { mountRootParcel } from 'single-spa'
function ProductPage() {
const parcelRef = useRef<HTMLDivElement>(null)
const parcelRef2 = useRef<ParcelObject | null>(null)
useEffect(() => {
if (!parcelRef.current) return
const parcel = mountRootParcel(
() => System.import('@company/cart'),
{
domElement: parcelRef.current,
singleSpa: window.singleSpa,
}
)
parcelRef2.current = parcel
return () => parcel.unmount()
}, [])
return <div ref={parcelRef} />
}
Or via React component:
import Parcel from 'single-spa-react/parcel'
function Layout() {
return (
<div>
<Parcel
config={() => System.import('@company/notifications')}
mountParcel={mountRootParcel}
wrapWith="div"
wrapClassName="notifications-wrapper"
/>
</div>
)
}
Communication between applications
Single-SPA recommends cross-microfrontend imports via import map:
// packages/shared-auth — separate npm package in import map
// "@company/auth": "https://cdn.example.com/auth/auth.js"
// in catalog:
import { getUser, eventBus } from '@company/auth'
const user = getUser()
eventBus.on('auth:logout', () => clearLocalCart())
Or via CustomEvent on window — without direct dependency:
// catalog publishes
window.dispatchEvent(new CustomEvent('@company/cart:item-added', {
detail: { productId: '123', quantity: 1 }
}))
// checkout listens
window.addEventListener('@company/cart:item-added', (e: CustomEvent) => {
checkoutStore.syncCartItem(e.detail)
})
Import map overrides — dev mode
npm install import-map-overrides
<!-- in index.html -->
<script src="https://cdn.jsdelivr.net/npm/import-map-overrides@2/dist/import-map-overrides.js"></script>
<import-map-overrides-full show-when-local-storage="devtools"></import-map-overrides-full>
Developer opens catalog in browser, clicks on overrides panel and changes URL @company/catalog to http://localhost:3001/catalog.js. Other microfrontends continue working from CDN.
Lifecycle error handling
import { addErrorHandler, getAppStatus, SKIP_BECAUSE_BROKEN } from 'single-spa'
addErrorHandler((error) => {
console.error('Single-SPA error:', error)
monitoring.captureException(error, {
tags: { appName: error.appOrParcelName },
})
// if application crashed — do not block everything
if (getAppStatus(error.appOrParcelName) === SKIP_BECAUSE_BROKEN) {
return
}
})
webpack.config.js for microfrontend
// systemjs compatible output
module.exports = {
output: {
library: { type: 'system' },
publicPath: '',
},
externals: ['react', 'react-dom', 'single-spa', 'react-router-dom'],
}
Key is externals. All shared dependencies are not included in bundle, loaded from import map.
Monitoring
import { addErrorHandler, getAppNames } from 'single-spa'
// mount time for each application
const mountTimes: Record<string, number> = {}
window.addEventListener('single-spa:before-app-change', (e: CustomEvent) => {
const { newAppStatuses } = e.detail
Object.keys(newAppStatuses).forEach((name) => {
if (newAppStatuses[name] === 'MOUNTED') {
mountTimes[name] = performance.now()
}
})
})
window.addEventListener('single-spa:app-change', (e: CustomEvent) => {
const { newAppStatuses } = e.detail
Object.keys(newAppStatuses).forEach((name) => {
if (newAppStatuses[name] === 'MOUNTED' && mountTimes[name]) {
const duration = performance.now() - mountTimes[name]
analytics.track('mf_mount_time', { app: name, duration })
}
})
})
What we do
We set up root-config with SystemJS and import maps, create single-spa adapters for each microfrontend (React, Vue, or Angular), set up import-map-overrides for developers, organize communication via event bus or shared modules, build CI/CD with versioning via import map.
Timeline: 8–15 days — basic architecture, root-config, 2–3 microfrontend applications, dev tools.







