Build-time микрофронтенды, или делай проще
- четверг, 14 мая 2026 г. в 00:00:13
Привет, меня зовут Александр Богданов, я ведущий фронтенд-разработчик стрима «Программы лояльности» в MWS. Наша команда отвечает за развитие и поддержку продуктов МТС Premium и МТС Cashback.
На 2025 год у нас было три активных пользовательских витрины, которые использовали разный стек, разные дизайн-системы, по-разному взаимодействовали с бэкенд-сервисами и имели еще целый набор мелких отличий. Из-за этого разработке приходилось поддерживать зоопарк решений и технологий, а также многократно повторять одни и те же действия на разных стеках. Высокие time-to-market и стоимость разработки одной фичи закономерно не устраивали бизнес, поэтому перед нами поставили задачу — перейти на архитектуру микрофронтендов.
В посте расскажу, как мы это сделали с помощью npm-пакетов и какие результаты получили.

После того как было принято решение о переходе на микрофронтенды, мы начали поиск вариантов, как будем его реализовывать. Нам было важно сохранить поддержку SSR и SEO и важно, чтобы разработка решения не заняла слишком много времени. Еще хотелось внедрить микрофронтенды в существующие витрины постепенно, а не ждать масштабного переезда на новое решение.
Сначала рассматривали Module Federation — о нем много статей, выступлений на конференциях, гайдов на YouTube и так далее. Но мы быстро поняли, что решение нам не подходит: возникли технические проблемы с SSR и SEO. Готовые решения работали плохо или не работали вообще, и стало понятно, что проблему нужно будет решать самостоятельно. А еще из доступных материалов мы поняли, что потребуется слишком много ресурсов: большинство выступлений коллег из других компаний начинались с рассказа об организации инфраструктурных команд.
Поэтому начали смотреть решения микрофронтендов с поддержкой SSR. Так вышли на Single SPA, но в этом случае потребовался бы переезд на новый фреймворк. В наших внутренних и внешних проектах мы используем Next.js и не планировали отказываться от него ради микрофронтендов. Также в случае переезда мы не смогли бы внедрять микрофронтенды в витрины постепенно. Поэтому вариант с Single SPA тоже отвергли.

В поисках решения мы наткнулись на термин build-time микрофронтенды — это когда независимые части frontend интегрируются в единое приложение еще до его развертывания. Интеграция выполняется с помощью бандлера на этапе сборки приложения. В этом случае микрофронтендом выступает обычный npm-пакет, который реализует в себе отдельную фичу или страницу веб-приложения.
Решение нам показалось простым в разработке, ведь мы продолжаем работать с привычными нам технологиями, отличие лишь в том, что теперь компоненты нужно будут разрабатываться в отдельных репозиториях, а не в одном. Сохраняется поддержка SSR и SEO, так как npm-пакеты не накладывают на это никаких ограничений. Не нужно менять CI/CD витрин, достаточно добавить сборку и публикацию npm-пакетов. А еще нет влияния на инструменты и мониторинги, используемые на проекте, например Sentry.
Об ограничениях мы тоже подумали. Сразу очевидно, что независимых релизов микрофронтендов мы не получим: для релиза новой версии npm-пакета всегда нужен будет релиз витрины. По этой же причине останутся и зависимости между командами при релизах витрины. В нашем случае релизы на проектах были не частыми — меньше одного в сутки, так что риски мы приняли и окончательно остановились на решении build-time.
Рассмотрим архитектуру решения, начнем с основных моментов. Различные файлы конфигурации и линтеров собраны в npm-пакеты, они составляют базу для конфигурации остальных элементов решения.
Затем идет блок инфраструктуры. В него входят UI-kit, реализованный на React и TailwindCSS, npm-пакет взаимодействия с backend на основе Apollo Client и шина данных для общения между микрофронтендами и root-приложением.

Теперь сами микрофронтенды, которые мы в команде называем виджетами. Виджет в своей реализации полагается на инфраструктуру и конфигурацию и использует npm-пакет widget-builder, который хранит в себе конфигурации для сборки и разработки виджета. Для удобства создания виджета был разработан пакет-утилита widget-creator.
Готовые виджеты интегрируются в root-приложение. Root-приложение также использует инфраструктуру решения и конфигурации в качестве базовых настроек, которые расширяются настройками фреймворка Next.js.
Теперь рассмотрим элементы архитектуры подробнее.
Для реализации стилей в UI-kit, виджетах и root-приложениях был выбран TailwindCSS. Мы в команде рассмотрели все варианты реализации стилей и в итоге сошлись на TailwindCSS, и вот почему.
TailwindCSS отлично подходит именно для решения build-time микрофронтендов, так как финальная сборка стилей будет происходить именно в root-приложении. Фреймворк при сборке опирается на стили, которые имеются в исходном коде root-приложения и виджетов и учитывает tree-shaking. Результатом такой сборки будет набор CSS классов без дублирования и без мертвого кода — только то, что нужно для стилизации витрины.
import path from "path" export default { presets: [require("@cashback/tailwind-config/base")], content: [ path.join(path.dirname(require.resolve("@cashback/indigo-ui")), "**/*.js"), path.join(path.dirname(require.resolve("@cashback/balance")), "**/*.js"), path.join(path.dirname(require.resolve("@cashback/user-settings-widget")), "**/*.js"), path.join(path.dirname(require.resolve("@cashback/registration-widget")), "**/*.js"), path.join(path.dirname(require.resolve("@cashback/banner-widget")), "**/*.js"), path.join(path.dirname(require.resolve("@cashback/mgts-widget")), "**/*.js"), path.join(path.dirname(require.resolve("@cashback/ondemand-widget")), "**/*.js"), "./src/**/*.{ts,tsx}", ], }
Также конфигурацию стилей TailwindCSS можно разместить в отдельном npm-пакете и шэрить между UI-kit, виджетами и root-приложениями. Конфигурации также подхватываются автокомплитом (требуются расширения) и eslint-плагинами.
В идеальном мире виджеты должны быть полностью независимы друг от друга, но на практике этого зачастую невозможно добиться и между ними возникает связность. Для общения между виджетом и виджетом или виджетом и root-приложением используется шина данных.
В качестве шины данных можно использовать любую библиотеку, реализующую паттерн «издатель — подписчик» (англ. publisher-subscriber). Мы выбрали библиотеку Postal.js, так как в ней имеется дополнительное свойство channel у события, что делает взаимодействие более гибким. Пришлось сделать форк, так как библиотека больше не поддерживается создателем и не имела поддержки Typescript, которая нам была нужна.
Формат взаимодействия по шине данных выглядит так: в свойстве channel указываем имя виджета, который отправляет или принимает событие или имя служебного канала. Свойство topic содержит уникальное для виджета имя события, из-за наличия channel можно не бояться, что будут пересечения. При отправке события можно передать произвольные данные в поле data, для которого добавлена возможность типизации.
postal.publish<CityChangePayload>({ channel: "user-settings", topic: "city.change", data: { city: { cityId: 1, cityName: "Москва" }, }, })
По соглашению в команде каждый виджет обязательно должен реализовывать два события. События готовности виджета, виджет инициализирован и готов к работе и событие ошибки, работа или отображение виджета невозможна.
К служебным каналам относятся каналы для отправки событий в МТС Аналитику, для событий в мониторинг Sentry и другие каналы общих механик.
Для взаимодействия нашего frontend с бэкенд-сервисами мы используем язык запросов GraphQL. На момент разработки виджетов на витринах уже использовалась библиотека Apollo Client, и мы решили сохранить это решение. Для унификации был создан npm-пакет, который содержит в себе функции создания экземпляра Apollo Client для виджетов и root-приложения.

Благодаря тому что Apollo Client существует внутри root-приложения в единственном экземпляре, запросы GraphQL кешируются и позволяют реализовать реактивный state, который используется как shared-state. Например, если какой-то виджет получил актуальный баланс кшбэка пользователя, баланс обновится во всех виджетах.
Следует отметить, что использование shared state в архитектуре микрофронтендов имеет свои плюсы и минусы, так как есть опасность рассинхронизации данных в shared state. В нашем случае в shared state хранятся только сырые данные от backend, которые могут только актуализироваться и жестко связаны с схемой GraphQL, так что конфликты данных не возникают.
Для быстрого старта разработки нового виджета, был сделан npm-пакет widget-creator, который устанавливается глобально на локальное окружение разработчика. Пакет позволяет создать шаблон виджета в заданной директории, который готов к запуску и разработке. Шаблон содержит в себе конфигурации, Hello World компонент и песочницу для разработки и тестирования.

За сборку виджетов отвечает пакет widget-builder, который хранит в себе webpack-конфигурации для сборки пакета и локальной разработки. В пакете реализованы различные варианты сборок, для локальной разработки, для передачи виджета партнерам на другой проект, сборка для развертывания песочницы на стенд и другие. Ничто не мешает расширять варианты сборок, виджеты технически можно собирать и для Module Federation.
Вся сборка происходит автоматически, реализован процесс CI/CD с набором необходимых quality-check. С локального окружения ничего не публикуем.

Каждый виджет имеет продакшн-зависимости на инфраструктуру, общие UI-библиотеки и так далее. Если npm-пакеты просто собрать без дополнительных настроек и интегрировать в root-приложение, мы не просто увеличим вес продакшн-сборки, а получим нерабочее приложение, так как некоторые библиотеки не работают в нескольких экземплярах.
Для того чтобы этого избежать, нужно использовать внешние (external) зависимости при сборке виджета.
const externals = [ 'react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', '@cashback/apollo-wrapper', '@cashback/indigo-ui', '@cashback/postal-premium', '@cashback/centrifuge-premium', '@apollo/client', 'swiper/react', 'class-variance-authority', 'react-hook-form', ] module.exports = { externals }
Во внешние зависимости добавляются React, элементы инфраструктуры, часто используемые UI-библиотеки. Для случаев, если один виджет имеет зависимость на другой, он также указывается в списке внешних зависимостей.
В процессе разработки возникают ситуации, когда для виджета требуется доработка UI-kit. При интеграции такого виджета в root-приложение, требуется, чтобы в root-приложении стояла версия UI-kit, содержащая новые доработки, так как UI-kit — это внешняя зависимость. Для этого используются одноранговые (peer) зависимости в package.json, с их помощью можно указать, какие версии npm-пакетов инфраструктуры требуются виджету в root-приложении:
{ "peerDependencies": { "@apollo/client": "^3.12.4", "@cashback/apollo-wrapper": "^4.0.22", "@cashback/indigo-ui": "^3.0.264", "@cashback/postal-premium": "^2.0.24", "react": "^19.0.0", "react-dom": "^19.0.0" }, }
При установке виджета в root-приложение, если версия npm-пакета инфраструктуры не будет соответствовать нужной, пакетный менеджер выведет предупреждение:

Нужно отметить, что не существует аналога одноранговых зависимостей для dev-зависимостей. Если указать такую зависимость в списке одноранговых, то они могут попадать в директорию node_modules в продакшн-сборке root-приложения. Это не влияет на конечного пользователя, но увеличивает размер финального образа и может приводить к проблемам, если у вас настроены проверки на наличие CVE-уязвимостей в npm-пакетах.
Для локальной разработки можно использовать версии зависимостей, которые развернуты локально. Можно подключать локальные изменения инфраструктуры в виджет или доработки виджета в root-приложение.
Например, создать символическую ссылку c npm-пакета в node_modules на локально развернутую версию. Это можно сделать с помощью пакетного менеджера, которые поддерживают команду link.
npm link yarn link pnpm link
Существуют решения для организации локального репозитория npm-пакетов, например yalc. Они аналогично реализуют механизм символических ссылок, но имеют расширенный функционал управления ими.
Если все-таки нужно развернуть на тестовый стенд версию npm-пакета, который все еще в разработке, CI/CD реализует возможность сборки dev-версии из любой feature-ветки в Gitlab.
У каждого виджета есть своя песочница. Она может быть запущена локально и развернута на тестовый стенд. Песочница состоит из рабочей области, в которой размещаются компоненты виджета и debug-панели.
Разработчик должен предусмотреть в debug-панели функции, которые позволяют воспроизвести все пользовательские пути виджета. Часть функций поставляется с шаблоном, часть дорабатывается под конкретный виджет в процессе разработки.

Мы используем песочницу виджета для разработки, тестирования, проведения дизайн-ревью и планируем запускать на ней end-2-end тесты.
Процесс внедрения виджета выглядит следующим образом:
Устанавливает npm-пакет виджета в root-приложение, по необходимости поднимает версии peer-зависимостей.
Настраиваем конфигурацию:
tailwind.config.js — чтобы учитывали стили виджета;
next.config.js — для сборки приложения;
в Apollo Client указываем url backend-сервисов, которые использует виджет.
Внедряем виджет в витрину. Размещаем React-компонент в нужном месте в интерфейсе, выполняем связку событий шины данных и при необходимости настраиваем поддержку SSR.
import { MgtsWidget as Mgts, POSTAL_CONFIG } from "@cashback/mgts-widget" import postal from "@cashback/postal-premium" import { useEffect } from "react" import { useObserveModal } from "@/modules/router" import { refetchTransactionsPreviewData } from "@/widgets/transactions-preview-widget/helpers" const MGTS_WIDGET_TEST_ID = "mgts-widget" const MODAL_NAME = "mgts" export const MgtsWidget = () => { // хук связывающий searchParams и открытие модального окна useObserveModal({ modalName: MODAL_NAME, onOpen: () => { postal.publish({ channel: POSTAL_CONFIG.channel, topic: POSTAL_CONFIG.topics.MGTS_OPEN, }) }, }) // обновление данных при успешном пополнении счёта useEffect(() => { const onPaymentSuccess = postal.subscribe({ channel: POSTAL_CONFIG.channel, topic: POSTAL_CONFIG.topics.payment_success, callback: () => { refetchTransactionsPreviewData() }, }) return () => { onPaymentSuccess.unsubscribe() } }, []) return <Mgts testId={MGTS_WIDGET_TEST_ID} /> }
Поддержка SSR реализуется так: виджет экспортирует функцию для выполнения GraphQL запроса на сервере. Функция хранится в npm-пакете виджета, чтобы не дублировать функционал на витрине и предотвратить случаи расхождения набора полей в запросе виджета и root-приложения.
import { ApolloClient, NormalizedCacheObject } from "@apollo/client" import { SOURCE_GRAPH_OBJ } from "@cashback/apollo-wrapper" import { BannersDocument, BannersQueryResult, BannersQueryVariables } from "@graphql/banner/schema" export const bannersServerQuery = ( apollo: ApolloClient<NormalizedCacheObject>, variables?: BannersQueryVariables ) => { return apollo.query<BannersQueryResult>({ query: BannersDocument, variables, context: { sourceGraph: SOURCE_GRAPH_OBJ.stories }, }) }
Вызов запроса размещаем в функции getServerSideProps фреймворка Next.js и затем используем реализацию SSR-библиотеки Apollo Client. Результат выполненных на сервере запросов агрегатируется и передается на клиент, где при инициализации экземпляра используется Apollo Client, который и выполняет гидратацию.
Основная проблема, с которой мы столкнулись, — это цепочки зависимостей. Рассмотрим пример, когда нам нужно добавить новый цвет для кнопки и использовать в виджете. Приходится делать следующее: добавить новый цвет в конфигурацию TailwindCSS, затем применить конфигурацию к UI-kit и доработать кнопку, применить новую версию UI-kit в виджете и в конце проинтегрировать обновленный виджет в root-приложение. Это технологическое ограничение работы с зависимостями, и от него, к сожалению, никак не уйти.
Другую проблему мы создали сами. Мы не уделили должного внимания шаблону виджета и кинулись в разработку, так как уже хотелось попробовать концепцию в действии. В процессе выяснилось, что мы не учли различные моменты реализации. Пришлось добавить или исправить их в шаблоне, но эти изменения не распространялись на уже сделанные виджеты. Из этого сделали вывод, что важно уделять больше внимания шаблонам проектов, ведь потраченное на них время в будущем сэкономит время на разработку.
В декабре 2025 года мы выпустили в продакшн витрину экосистемного модуля МТС Cashback, которая была целиком выполнена на виджетах. Проект заменил собой старое решение на Svelte. По новой концепции багов найдено не было, процессы остались такими же, новая витрина просто встала на место старой.
Пока мы занимались экосистемным модулем, попутно реализовали различные маркетинговые акции в формате виджетов и запускали их на разных витринах. Теперь акционную механику можно сделать один раз в формате виджета, а потом просто интегрировать на нужные витрины, что существенно экономит время.
Сейчас мы работаем над витриной МТС Cashback, которую решили переработать из-за большого количества legacy: берем виджеты экосистемного модуля и из них собираем новую витрину.
Концепция виджетов позволила решить существующие проблемы. Теперь:
бизнес рад, что сократился time-to-market;
разработчики унифицируют стек и подходы к разработке, поэтапно избавляются от legacy;
команды перестали сталкиваться между собой при разработке фичей и инфраструктура не отнимает много времени на поддержку.
Так что у нас получилось рабочее решение на npm-пакетах, которое можно использовать в продакшне.