Предыстория
Около года назад я написал первую часть статьи о Single SPA — о том, как я выбирал архитектурный подход, боролся с монолитом и пробовал собрать первые микрофронтенды. В статье были разобраны базовые принципы работы Single SPA, подключение importmap, сравнение с другими архитектурными решениями и настройка сборки на Webpack и Vite.
Эта статья — продолжение цикла. Здесь я поделюсь практикой: как на самом деле живётся с Single SPA, какие есть подводные камни и что можно вынести в виде рекомендаций.

Сразу скажу: Single SPA — не «серебряная пуля» и уж точно не современный тренд фронтенд-разработки. В 2025-м появилось еще больше других подходов, которые решают похожие задачи иначе. Но Single SPA всё ещё актуален там, где есть огромные легаси-системы, которые невозможно переписать с нуля. И вот именно для таких кейсов мой опыт может быть полезен.
Связываем микрофронты в единое приложение
В первой части я остановился на самом интересном месте:
Теперь осталось связать наши микрофронты в единое приложение...
По сути, именно здесь и начинается настоящая магия Single SPA.
Итак, у нас есть два типа артефактов:
App Shell (хост) — приложение, которое регистрирует и монтирует микрофронты.
Микрофронты (MF) — самостоятельные сервисы, экспортирующие методы
bootstrap
,mount
,unmount
.
Идея простая: хост подтягивает точки входа MF через importmap, а single-spa
решает, что и когда монтировать.
Именно он превращает набор разрозненных сервисов в «единое приложение» для пользователя.
Точка входа MF — всё как в первой части статьи: бандл, экспортирующий bootstrap/mount/unmount
. Здесь повторять не буду.
Что там с хостом?
Для глубокого понимания происходящего можно опираться на мой репозиторий с примером хоста, собирающегося на Vite.
index.html
подключает es-module-shims и две импортмапы, а также скрипт с layout (app-shell.ts):
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link href="./index.css" rel="stylesheet" />
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<meta name="description" content="Single SPA Example App Shell" />
<meta name="importmap-type" content="importmap-shim" />
<title>Single SPA Example App Shell</title>
<script async src="https://ga.jspm.io/npm:import-map-overrides@6.1.0/dist/import-map-overrides.js"></script>
<script async src="https://ga.jspm.io/npm:es-module-shims@2.0.0/dist/es-module-shims.js"></script>
<script type="importmap-shim" defer src="/shared.importmap.json"></script>
<script type="importmap-shim" async src="/modules.importmap.json"></script>
<import-map-overrides-full
show-when-local-storage="devtools"
></import-map-overrides-full>
<% if (isLocal) { %>
<script>window.esmsInitOptions = { mapOverrides: true }</script>
<script type="module-shim" src="/devSupport.ts"></script>
<% } %>
</head>
<body>
<script type="module-shim" async src="/app-shell.<%= isLocal ? 'ts' : 'js' %>"></script>
</body>
</html>
Про импортмапы: в моем примере shared.importmap.json
указывает на общие зависимости, а modules.importmap.json
на сами входные точки MF соответственно.
Основной скрипт хоста (layout) может содержать такую структуру (пример из документации):
// single-spa-config.js
import { registerApplication, start } from "single-spa";
// Simple usage
registerApplication(
"app2",
() => import("src/app2/main.js"),
(location) => location.pathname.startsWith("/app2"),
{ some: "value" },
);
// Config with more expressive API
registerApplication({
name: "app1",
app: () => import("src/app1/main.js"),
activeWhen: "/app1",
customProps: {
some: "value",
},
});
start();
Мне же такой вариант не очень зашел вследствие любви к декларативности, тем более вместо ручного вызова registerApplication
для каждого модуля имеется возможность использовать Layout Definition. Он позволяет описать структуру приложения декларативно, прямо в JSON или напрямую в HTML, а затем сгенерировать из этого роуты и список микрофронтов.
Пример подхода JSON Layouts:
import { constructRoutes } from 'single-spa-layout'
const routes = constructRoutes({
routes: [
{
type: 'route',
path: 'tickets',
routes: [
{
type: 'application',
name: '@organization/react-app'
}
],
default: false
}
]
})
Пример подхода HTML Layouts:
<template id="single-spa-layout">
<single-spa-router mode="history">
<main>
<route default>
<application name="@organization/react-app"></application>
</route>
</main>
</single-spa-router>
</template>
Более подробно со всеми нюансами можно ознакомиться в документации.
Самый большой плюс такого подхода — возможность легко добавлять новые модули без пересборки хоста (особенно, если layout инжектится сервером).
Локальная разработка и HMR
Как запустить все это добро?
Поднимаем микрофронт на Vite в режиме dev (у меня это
http://localhost:3001
).
Важно: именноvite dev
, неpreview
.-
Поднимаем хост (у меня
http://localhost:8000
).
Вindex.html
хоста должно заранее лежать подключениеes-module-shims
, импортмап и скрипт для корректной работы HMR при локальной разработке.<% if (isLocal) { %> <script>window.esmsInitOptions = { mapOverrides: true }</script> <script type="module-shim" src="/devSupport.ts"></script> <% } %>
-
Говорим хосту, где брать микрофронт. Тут два удобных пути:
Вариант A: через
import-map-overrides
(рекомендую). Включаем панель через браузерную консоль:localStorage.setItem('devtools','true'); location.reload();
Далее есть два удобных варианта использования:
1) Через UI
-
Включаем Dev tools (см. выше) → на странице появится панель.
Интерфейс importmap-overrides
-
Тыкаем Add new module и вбиваем ключ MF и URL на локальную входную точку (
/src/...
). Кнопка Apply override — ready! Выключить override — Disable override или через Reset all overrides.
2) Через консоль
// включить/поменять importMapOverrides.addOverride( '@organization/react-app', 'http://localhost:3001/src/react-app.tsx' ) // выключить один importMapOverrides.removeOverride('@organization/react-app') // сбросить все importMapOverrides.resetOverrides()
Вариант B: прописать локально в
modules.importmap.json
.
На время разработки можно напрямую прописать:{ "imports": { "@organization/react-app": "http://localhost:3001/src/react-app.tsx" } }
Плюс — минимум кликов, минус — правите файл (и не так удобно переключаться).
-
Открываем маршрут, где монтируется MF (в моём примере —
/tickets
) и начинаем работать: меняем код в MF — обновления прилетают без перезагрузки.
Зачем нужен devSupport.ts и что он даёт
Хост в реальности тянет MF через importmap + es-module-shims, то есть без Vite-клиента на странице. Поэтому просто "перекинуть" импорт на localhost
мало — HMR сам по себе не стартанёт.
В связи с этим я написал небольшой вспомогательный скрипт devSupport.ts
, который и послужит решением проблемы:
const uniq = <T>(a: T[]) => Array.from(new Set(a))
const isDevEntry = (u: string): boolean => {
try {
const { hostname } = new URL(u, location.href)
return hostname.toLowerCase().includes('localhost')
} catch {
return false
}
}
const readImportMaps = async (): Promise<Record<string, string>> => {
const scripts = Array.from(
document.querySelectorAll<HTMLScriptElement>(
'script[type="importmap-shim"]'
)
)
const out: Record<string, string> = {}
for (const s of scripts) {
try {
const text = s.src
? await (await fetch(s.src)).text()
: s.textContent ?? ''
const map = JSON.parse(text)
Object.assign(out, map?.imports ?? {})
} catch {}
}
return out
}
const readOverrides = (): Record<string, unknown> => {
const imo = (window as any).importMapOverrides
const maps = imo?.getOverrideMap?.()?.imports ?? {}
return Object.fromEntries(
Object.entries(maps).filter(([k]) => !imo?.isDisabled?.(k))
)
}
const injected = new Set<string>()
const injectOnce = (key: string, el: HTMLScriptElement) => {
if (injected.has(key)) return
injected.add(key)
document.head.append(el)
}
const installFor = async (origin: string) => {
const isVite = await fetch(`${origin}/@vite/client`, { method: 'HEAD' })
.then((r) => r.ok)
.catch(() => false)
if (!isVite) return
const client = document.createElement('script')
client.type = 'module-shim'
client.src = `${origin}/@vite/client`
injectOnce(`${origin}::vite-client`, client)
const hasRefresh = await fetch(`${origin}/@react-refresh`, { method: 'HEAD' })
.then((r) => r.ok)
.catch(() => false)
if (hasRefresh) {
const pre = document.createElement('script')
pre.type = 'module-shim'
pre.text = `
import { injectIntoGlobalHook } from '${origin}/@react-refresh';
injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (t) => t;
window.__vite_plugin_react_preamble_installed__ = true;
`
injectOnce(`${origin}::refresh`, pre)
}
}
const collectDevOrigins = async (): Promise<string[]> => {
const maps = await readImportMaps()
const overrides = readOverrides()
const allUrls = [...Object.values(maps), ...Object.values(overrides)].filter(
(u): u is string => typeof u === 'string' && Boolean(u)
)
const devUrls = allUrls.filter(isDevEntry)
return uniq(devUrls.map((u) => new URL(u, location.href).origin))
}
const installAll = async () => {
const origins = await collectDevOrigins()
await Promise.all(origins.map(installFor))
return true
}
;(window as any).__vitePreambleReady = installAll()
Если не вдаваться в подробности, то скрипт проходит по импортмапам и overrides, находит все локальные origin
’ы (например, localhost:*
) и единожды для каждого origin
подключает:
@vite/client
— собственно HMR;(опционально)
@react-refresh
— если ваш MF на React и у вас стоит@vitejs/plugin-react
или@vitejs/plugin-react-swc
. В обоих случаях Fast Refresh работает, а с swc ещё и сборка шустрее =)
Для Vue / Angular ничего дополнительного не нужно: Vite-клиента хватает.
Несколько микрофронтов? Без проблем. Делаете overrides (или записи в importmap) на каждый ключ: разные порты — разные origin
’ы. devSupport.ts
сам поставит HMR-клиент по одному на origin, дубликатов не будет. Если два MF сидят на одном порту — клиент подключится один раз и обслужит оба.
Проблемы на практике
На бумаге всё выглядит очень красиво: декларативный layout, изоляция сервисов, независимый деплой. Но при реальном использовании появляются нюансы, о которых стоит знать заранее.
Несогласованность роутинга
Каждый микрофронт живёт своей жизнью и хочет свой роутер (e.g., react-router, vue-router, etc.). В итоге два уровня маршрутизации:
Хост рулит глобальными урлами (e.g.,
/tickets
, etc.).MF рулит своими внутренними путями — относительно базового префикса.
Если базовый путь не прокинуть — будет мешанина: редиректы в никуда, дублирующиеся слэши, 404 не там, где надо.
Пример: в layout у MF маршрут tickets
. Значит, react-router
внутри @organization/react-app
должен знать, что его базовый путь — /tickets
.
Решение: пробрасывать basename
через props
из хоста в микрофронт:
const routes = constructRoutes({
routes: [
{
type: 'route',
path: 'tickets',
routes: [
{
type: 'application',
name: '@organization/react-app',
// здесь задаём значения пропсов для микрофронта
props: { basename: '/tickets' },
},
],
default: false,
},
],
})
А внутри микрофронта рутовый компонент в свою очередь может принять эти пропсы:
// Входная точка MF
import React from 'react'
import ReactDOMClient from 'react-dom/client'
import singleSpaReact from 'single-spa-react'
import Root, { type HostContext } from '@/app/providers'
export const { bootstrap, mount, unmount } = singleSpaReact({
React,
ReactDOMClient,
rootComponent: Root,
errorBoundary(err, info, props: HostContext) {
console.error('err:', err)
console.error('info:', info)
console.error('props:', props) // здесь будет { basename: '/tickets' }
return null
},
})
// Root-компонент
import { BrowserRouter } from 'react-router-dom'
export type HostContext = {
basename?: string
}
const Root = ({ basename }: HostContext) => {
console.log('Я реально получил пропсы!', basename)
return (
<BrowserRouter basename={basename}>
{/* ...локальные роуты... */}
</BrowserRouter>
)
}
export default Root
Вуаля!

Конфликты зависимостей
Классика жанра: один MF на React 19, другой ещё на 18; где-то router v6, где-то v5. В лучшем случае — два React’а в бандле. В худшем — runtime падает.
Что делать:
Выделить “общие зависимости” в отдельную импортмапу (условно
shared.importmap.json
) и жёстко закрепить версии. Так вы гарантируете единые версии библиотек для всех MF.-
Если уж одной версией не обойтись (жизнь сложная) — используйте
scopes
в импортмапе: разные микрофронты получают свои версии библиотек, без взаимного влияния.Пример
shared.importmap.json
:
{
"imports": {
"react": "https://ga.jspm.io/npm:react@18.3.1/index.js",
"react-dom": "https://ga.jspm.io/npm:react-dom@18.3.1/index.js",
"react-router": "https://ga.jspm.io/npm:react-router@6.23.1/dist/main.js",
"react-router-dom": "https://ga.jspm.io/npm:react-router-dom@6.23.1/dist/main.js",
"single-spa": "https://ga.jspm.io/npm:single-spa@6.0.1/lib/es2015/esm/single-spa.min.js",
"single-spa-layout": "https://ga.jspm.io/npm:single-spa-layout@2.2.0/dist/esm/single-spa-layout.min.js"
},
"scopes": {
"https://ga.jspm.io/": {
"@remix-run/router": "https://ga.jspm.io/npm:@remix-run/router@1.16.1/dist/router.cjs.js",
}
}
}
Чтобы не вбивать каждую зависимость вручную, выручает Import Map Generator (JSPM) — онлайн-сервис и CLI, которые собирают валидную импортмапу с корректными скоупами зависимостей.
Подробнее — сюда.
Props и конфигурация через layout
Сильная сторона single-spa-layout
— можно скормить конфиг прямо из разметки: feature-флаги, базовые урлы API, локаль / тенант — всё сюда.
Исходя из примера выше с basename
при использовании подхода JSON Layouts получается такой конфиг:
{
type: 'application',
name: '@organization/react-app',
// прокидываем необходимые пропсы
props: {
featureFlag: 'new-dashboard',
apiBase: '/api/v2',
locale: 'ru-RU'
}
}
Для HTML Layouts используется другой подход, так как определить сложные типы данных в HTML не так просто, поскольку атрибуты HTML всегда являются строками. Чтобы обойти эту проблему, single-spa-layout позволяет вам передать ваши пропсы в HTML, но определять их значения в layout:
<application name="settings" props="authToken,loggedInUser"></application>
import { constructRoutes } from "single-spa-layout";
const data = {
props: {
authToken: "fds789dsfyuiosodusfd",
loggedInUser: fetch("/api/logged-in-user").then((r) => r.json()),
},
};
const routes = constructRoutes(
document.querySelector("#single-spa-template"),
data,
);
Где удобно:
A/B-тесты без перекомпиляции;
мульти-тенантность;
переключение backend-эндпоинтов.
Где осторожнее:
Props статичны на момент загрузки. Если нужно менять динамически — генерируйте layout сервером (SSR/SSG) и отдавайте на лету.
А если микрофронт упадет?
CDN дернулся, релиз сломался, 404
вместо module.js
. Без страховки Host завалится следом.
Быстрое решение: оборачиваем loadApp
и отдаём безопасную заглушку:
const applications = constructApplications({
routes,
loadApp: async ({ name }) => {
try {
return await import(/* @vite-ignore */ name as string)
} catch (e) {
console.error(`Не удалось загрузить ${name}`, e)
return {
bootstrap: () => Promise.resolve(),
mount: () => Promise.resolve(),
unmount: () => Promise.resolve()
}
}
}
})

Параллельно имеет смысл показывать «плашку» или скелетон в зоне микрофронта и логировать инцидент.
Overengineering
Самая частая ловушка. Микрофронтов становится слишком много, каждый тянет свои зависимости, свои роутеры, свои рантаймы. Сначала весело, потом больно.
Как не улететь в космос:
Делите только по реальным зонам ответственности (команды / домены), а не «каждую кнопку — в свой MF».
Заводите единый список shared зависимостей (React, Router, стейт-менеджеры, дата-форматтеры и т.д.) и следите за версиями централизованно.
Ограничьте «самоуправство» на уровне CI: линтеры / валидаторы мап, минимальный набор правил для сборок.
Что по тестированию?
В данном вопросе речь должна идти именно о E2E тестах (юниты — зона каждого микрофронта отдельно).
Цель: убедиться, что хост + микрофронты вместе живут нормально: корректно роутятся, монтируются / размонтируются и не валят друг друга.
Что проверять?
Навигацию по layout. Переход на URL микрофронта монтирует нужный микрофронт; внутренние роуты работают относительно
basename
.Жизненный цикл. Уход со страницы вызывает
unmount
, возврат —mount
(без "висящих" таймеров и подписок).Деградацию. Если MF не загрузился / упал — у хоста показывается заглушка / ошибка в зоне MF, а не "белый экран".
Что гонять?
Staging через ингрессы — максимально близко к прод-реальности.
Хост:
https://staging.app.yourcompany.com
MF:
https://staging-mf-*.yourcompany.com/...
Общие зависимости — из
shared.importmap.json
на CDN.
На стейджинге / проде импортмапы уже смотрят на собранные бандлы. Никаких @vite/client, никаких overrides — фиксированная среда, меньше случайностей.
Чем гонять?
Любой из «большой двойки» — Playwright или Cypress. Оба подходят, выбирайте по команде / опыту.
Важно
Зафиксируйте импортмапы на необходимом стенде (immutable версии), чтобы тесты не ловили "дрейф" зависимостей.
Отключите Service Worker (если он есть) на время E2E (флагом через env) или добавляйте
?nocache=...
, чтобы не упираться в кеш.Тестовые данные / feature-флаги. Договоритесь про предсказуемые сиды / флаги для сценариев (иначе флак будет вечным).
Наблюдаемость. Снимайте логи и сетевые ошибки страницы — проще понять, какой MF уронил связку.
Глубина покрытия
Пара smoke-сценариев на каждый раздел (роут layout → базовые действия внутри MF).
Один сценарий на размонтирование / повторное монтирование.
Один сценарий на fallback при недоступности MF.
Всё остальное — в юнит- / интеграционные тесты внутри команд, отвечающих за микрофронт.
Итого по тестированию
E2E должны подтверждать, что «склейка» работает: роутинг хоста, basename
MF, жизненный цикл и деградация.
Не перегреваем пирамиду — проверяем именно интеграцию между хостом и микрофронтами, а не всю бизнес-логику каждого приложения.
CI/CD: как это живёт в проде
В микрофронтендах важен один простой принцип: хост не пересобираем каждый раз, когда обновляется любой MF. Он должен узнавать о новых версиях по импортмапе.
Базовый use case

Сборка MF.
Каждая команда билдит свой MF (module.js
+ assets с хешами).Публикация артефактов.
Катим на CDN / облако (e.g., S3 + CloudFront, GCS + CDN, etc.). Артефакты immutable: в путях есть версия / хеш.Обновление импортмап.
После успешной публикации обновляем мапу так, чтобы ключ MF указывал на новый URL (версированный). Это атомная операция и её удобно делать через import-map-deployer (см. ниже).Хост.
Хост не пересобираем — он на старте (или периодически) тянет импортмапу микрофронтендов и монтирует их по актуальным ссылкам.Смоук + промоушн.
Пробежали e2e на стейджинге → "продвинули" ту же запись импортмап на прод (без перекладки артефактов).
Зачем нужен import-map-deployer
Это маленький сервис / CLI от single-spa, который:
хранит и меняет импортмапы для окружений (dev / staging / prod);
даёт историю / реверсии (быстрый rollback);
делает атомные апдейты (без состояния «полумежду»);
предоставляет HTTP API для пайплайнов.
Как внедрить по-простому:
поднимаете
import-map-deployer
(как сервис или через serverless);указываете, где хранить мапы (S3 / файл / репозиторий);
в CI микрофронта после загрузки на CDN делаете POST в деплойер:
«обнови ключ@organization/react-app
→ наhttps://cdn/.../react-app.<hash>.js
»;хост читает
modules.importmap.json
с URL деплойера / CDN — и всегда видит свежие версии.
Более подробно с import-map-deployer можно ознакомиться в данном гайде.
Пара практичных правил
Версии только immutable. Бандлы с хешами,
Cache-Control: immutable, max-age=31536000
. Ссылку на них меняет только импортмапа.Мапа с коротким кешем.
Cache-Control: max-age=30–60s
+ETag
. Хосту важна свежесть именно импортмапы.Общие зависимости — отдельной импортмапой.
shared.importmap.json
держим централизованно и обновляем синхронно (через тот же деплойер), чтобы не разъехались версии библиотек.Промо окружений. Стадия
staging
→ «promote» той же мапы наprod
после смоуков. Никаких «пересборок под prod».Canary по имени. Хотите потестить часть трафика — заведите параллельный ключ (
@organization/react-app@canary
) и рулите раскладкой на стороне хоста / feature-флага.Быстрый откат. Любая проблема — «revert import map to №1» и вы снова на стабильной версии за секунды.
Повторный краткий ликбез
Микрофронт собирается и публикуется на CDN как версионированные, неизменяемые артефакты. Затем через Import Map Deployer обновляется modules.importmap.json
на staging, запускаются e2e / смоук-тесты, после чего выполняется promote этой же мапы на prod.
Хост деплоится независимо и редко; в рантайме он подгружает две мапы — shared.importmap.json
с CDN и modules.importmap.json
из IMD — и монтирует актуальные версии без собственной пересборки. Откат — переключение версии modules.importmap.json
в IMD.
Кеширование браузером и Service Worker
Что кешируем и как
Артефакты MF (JS / CSS, шрифты) — immutable.
Отдаём с CDN сCache-Control: public, max-age=31536000, immutable
. Имена — с хэшами. Обновление = новый URL вmodules.importmap.json
. Браузер сам подтянет свежие версии.Импортмапы — короткий TTL.
modules.importmap.json
иshared.importmap.json
:Cache-Control: max-age=30–60, must-revalidate
+ETag/Last-Modified
. Хосту важна актуальность мап, не их кеш.HTML / хост-бандл — как обычно.
HTML —no-cache
/max-age=0, must-revalidate
; бандл хоста — можно кешировать, но без фанатизма (часть ссылок на модули тянется из импортмапы в рантайме).
Service Worker — где и зачем
SW ставим на хосте, не в каждом MF.
SW действует в пределах origin + scope. Он не контролирует CDN и чужие домены MF. Поэтому централизуем офлайн / кеш-политику на хосте.-
Стратегии по умолчанию:
Импортмапы — network-first (не закешировать "навечно").
HTML — network-first.
Статика хоста — stale-while-revalidate.
Артефакты MF с CDN — доверяем CDN (долгий кеш на стороне CDN + версионные урлы), SW не трогаем.
Обновления SW.
ВключитеskipWaiting/clientsClaim
(илиregisterType: 'autoUpdate'
при использовании Vite PWA), и покажите пользователю тост «Доступна новая версия — Обновить», это снимет «залипшие» клиенты.Dev / CI.
На dev и в E2E выключайте SW, чтобы не ловить фантомные кеши (флагом окружения или отдельнымindex.html
без регистрации SW).
Выводы: плюсы, минусы и когда Single SPA действительно уместен
Что в нём сильного
Плавная миграция с монолита. Можно «выпиливать» куски по одному, не стопоря разработку всего продукта.
Независимость команд. Отдельные репозитории, отдельные релизы, изоляция зон ответственности.
Технологическая нейтральность. Реально уживаются React / Vue / Angular / Web Components — хосту всё равно, лишь бы были
bootstrap/mount/unmount
.Безопасный деплой. Сломался один микрофронт — остальные живут (если есть фолбэки).
Гибкий CI/CD. С импортами + IMD (import-map-deployer) можно делать promote / rollback без пересборки хоста.
За что придётся заплатить
Оверхед на координацию. Общие зависимости, версии, импортмапы, IMD, правила кеширования — всё это надо дисциплинированно поддерживать.
Производительность. Разные фреймворки в одной странице → дубли рантаймов, рост веса бандла, сложнее оптимизировать критический рендер.
DX и тесты. Локальная разработка, HMR, E2E на стейджинге — всё чуть сложнее, чем в «одном большом приложении».
Коммуникации между MF. Шины событий, контракт пропсов, навигация, аутентификация — нужно продумывать заранее.
SSR / SEO. Рендер на сервере и консистентный HTML сложнее, чем у монолитных фреймворков/мета-фреймворков.
Когда Single SPA — хорошая идея
У вас крупный легаси-монолит, который нельзя переписать с нуля.
Есть несколько зрелых команд с разными стеками / ритмами релизов.
Важны независимые деплои частей продукта и быстрый откат на уровне импортмапы.
Продукт уже "тяжёлый", а риски больших релизов надо снижать.
Когда лучше не надо
Новый продукт или средний по размеру проект.
Единый стек и нет потребности в изоляции релизов.
Нужны SSR, RSC / стриминг, SEO-критичные страницы и тонкая оптимизация на уровне фреймворка.
80% функционала — общий код / дизайн-система, а «разделение» даст больше боли, чем пользы.
Что выбрать в 2025 вместо (или рядом с) Single SPA
Один фреймворк + модульная архитектура: монорепа (Nx / Turbo), «пакетная» сегментация, дизайн-система, изолированный релиз фич через Module Federation (в том числе для Vite —
vite-plugin-federation
) без кросс-фреймворковости.Мета-фреймворки для приложений: Next.js / Nuxt / SvelteKit / SolidStart — SSR, RSC / стриминг, отличный DX, встроенный роутинг и сборка.
Островная архитектура: Astro (это не про микрофронтенды, но отличная стратегия для перфоманса и композиции виджетов / страниц).
Web Components: когда нужна лёгкая интеграция независимых виджетов без тяжёлого рантайма и с хорошей переиспользуемостью.
To sum up
Single SPA — это не модная «серебряная пуля», а рабочий инструмент миграции и изоляции в больших системах. Он блестяще решает задачу «как жить с тем, что уже есть», но требует взрослой инженерной культуры: импортмапы, единые версии зависимостей, правила деплоя и кеширования, etc.
Для новых проектов чаще выгоднее выбрать современный мета-фреймворк или модульную архитектуру в одном стеке. Для легаси-продуктов, где переписывать всё — роскошь, Single SPA всё ещё «игра стоит свеч».
Katrain
Можно просто не выносить общие зависимости и дать каждому микрофронту полную свободу действий — просто дай им свой базовый path, и дальше не твое дело, что у них там. Тот же importmap можно не использовать и спокойно делать подключение внутри хоста.
single-spa — это про полную независимость хоста и микрофронтов, просто шарим какое-то состояние. Если хочешь стандартизировать работу — это уже к любой другой либе для микрофронтов.
За два года работы в продакшене с этой либой и поддержкой хоста — абсолютно противоположное мнение. Буквально, даю очередной микрофронтовой тиме условно полную свободу действий. Они могут хоть 16 react там использовать и деплоиться, когда захотят т.к. в хосте важно только ссылка на JS-бандл микрофронта