Micro Frontend Practical Guide — Module Federation 2.0, Architecture Design for Teams of 5 or More
On the morning of a deployment day, a Slack message arrives: "Are you deploying today?" Back when our teams had grown to seven and we all shared a single frontend repo, every time that message repeated, it made me rethink the architecture. If one team pushed their deployment first, another couldn't get their code in — and a single rollback would wipe out three teams' work at once.
Micro frontends (MFE) are not simply "technology for splitting apps." They're closer to organizational design — cutting inter-team dependencies and giving each domain genuine, independent ownership. This article covers everything from the core concepts of MFE to a practical Module Federation 2.0 setup, all the way to the trade-offs you absolutely must weigh before adopting it.
Companies like Spotify, Walmart, and Olive Young have already adopted it and shortened their release cycles, but there are equally real cases where misconfigured shared settings caused an average 15% bundle size increase, or teams were overwhelmed by operational complexity. Let's explore together when adoption is effective — and when it's better to avoid it.
Table of Contents
Core Concepts
Understanding the MFE Structure First
Micro frontends are an architectural pattern that breaks a single massive SPA into small units that can be independently developed, built, and deployed. It's essentially transplanting the philosophy of microservice architecture (MSA) — already familiar from the backend world — directly onto the frontend layer.
┌─────────────────────────────────────┐
│ Shell Application │
│ (공통 헤더 · 라우팅 · 인증) │
│ │
│ ┌───────────┐ ┌───────────────┐ │
│ │ Team A │ │ Team B │ │
│ │ (검색 MFE)│ │ (결제 MFE) │ │
│ └───────────┘ └───────────────┘ │
│ │
│ ┌───────────────────────────────┐ │
│ │ Team C (추천 MFE) │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘There are three main components.
- Shell (Container) App: The host responsible for the overall layout, shared navigation, routing, and authentication. It provides mount points for each micro app and manages their lifecycle. In my experience, the Shell app gradually becoming bloated is the first problem that tends to arise — under the banner of "shared," everything gets stuffed in here.
- Remote (Micro) App: An independently deployable unit responsible for a specific domain, such as checkout, search, or cart. It has its own bundle and is dynamically loaded by the Shell at runtime.
- Shared Layer: Code shared across multiple apps, such as the design system, common utilities, and state management. The key is preventing duplicate loading.
Three integration approaches: ① Build-time integration (npm packages) — simplest, but independent deployment is not possible ② Server-side composition (ESI, SSI) — SSR-friendly ③ Runtime client-side integration (Module Federation, single-spa, iframe) — the current mainstream
Why Module Federation 2.0 Matters Now
As of April 2026, Module Federation — which originated in Webpack 5 — has reached a stable 2.0 release. The biggest change is that the runtime is now completely decoupled from the build tool. Previously it was tied to Webpack, but now Rspack, Rollup, Vite, and Metro all operate on the same runtime. With official support for Next.js and Storybook as well, the barrier to entry in production has dropped considerably.
// Remote 앱 webpack.config.js
const { ModuleFederationPlugin } = require('@module-federation/enhanced');
new ModuleFederationPlugin({
name: 'checkout',
filename: 'remoteEntry.js', // Shell이 이 파일을 참조해 모듈을 동적 로딩
exposes: {
'./CheckoutWidget': './src/CheckoutWidget',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
})// Shell 앱 webpack.config.js
new ModuleFederationPlugin({
name: 'shell',
remotes: {
checkout: 'checkout@https://checkout.cdn.example.com/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
})What is
remoteEntry.js? It's a runtime module registry that tells consumers which modules the Remote app exposes publicly. If you're new to Module Federation, think of this file as the "public API list of the Remote app." The Shell only needs to know this one file to pull out the modules it needs at runtime.
The singleton: true setting is critical — omit it and you'll encounter the rather annoying situation where multiple React instances are loaded, causing Hook errors. I fumbled around quite a bit without this setting when I first started.
One more thing — the shared config also has an eager option. Setting eager: true includes the library in the initial bundle without an async chunk, which speeds up the first load but increases bundle size. In most cases the default (async loading) is more appropriate, and eager is best used sparingly, only for libraries that are absolutely required on the initial render.
The Trend to Watch in 2026 — Native ESM Federation
Beyond core concepts, let me introduce one trend drawing the most attention right now. This approach federates remote modules using only browser-native ESM and Import Maps — no build tools needed. Host apps can compose Remote modules without complex bundler configuration.
<!-- index.html - Import Map 방식 -->
<script type="importmap">
{
"imports": {
"checkout": "https://checkout.cdn.example.com/v2.1.0/index.js",
"search": "https://search.cdn.example.com/v3.0.0/index.js"
}
}
</script>
<script type="module">
import { CheckoutWidget } from 'checkout';
import { SearchBar } from 'search';
</script>There are still things to consider in terms of browser support and caching strategies. Import Maps are limited to modern browsers, and CDN caching and versioning strategies need to be designed alongside it. Even so, given that it can dramatically reduce build pipeline complexity, this looks set to spread rapidly in the future.
Practical Application
The three examples below are each independent options, but in real projects they are often used in combination. You might set up the monorepo skeleton with Turborepo, configure integrated deployment with Vercel, and handle inter-app communication with EventBus.
Example 1: Setting Up the MFE Skeleton with a Turborepo Monorepo
In practice, it's common to adopt a monorepo structure when starting with MFE. It lets you share code easily while keeping deployments independent. If you're new to pnpm monorepos, it's worth checking out the Turborepo official Getting Started first.
# 프로젝트 구조
apps/
shell/ # 호스트 앱 (포트 3000)
checkout/ # 결제 MFE (포트 3001)
search/ # 검색 MFE (포트 3002)
packages/
design-system/ # 공유 UI 컴포넌트
utils/ # 공유 유틸리티
turbo.json
pnpm-workspace.yaml# pnpm-workspace.yaml
# pnpm에게 어디서 워크스페이스 패키지를 찾아야 하는지 알려주는 파일입니다
packages:
- 'apps/*'
- 'packages/*'// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}dependsOn: ["^build"] means "before building this app, build the packages it depends on first." When packages/design-system must be built before apps/checkout, this setting means you don't have to worry about the order.
| Command | Description |
|---|---|
pnpm turbo dev |
Start dev servers for all apps simultaneously |
pnpm turbo build --filter=checkout |
Selectively build only the checkout app |
pnpm turbo build --affected |
Build only changed apps (CI optimization) |
Example 2: Next.js App Router + Vercel-Based MFE Setup
Once you have the monorepo skeleton in place, the next question is how to actually compose each app. If you're already using the Next.js App Router, the @vercel/microfrontends package is the fastest entry point. It's also well-suited for services where SEO matters.
pnpm add @vercel/microfrontends// vercel.json
// 실제 동작 여부는 공식 문서(https://vercel.com/docs/microfrontends)에서 확인하세요
{
"microfrontends": {
"applications": {
"checkout": {
"development": { "origin": "http://localhost:3001" },
"production": { "origin": "https://checkout.my-app.com" }
},
"search": {
"development": { "origin": "http://localhost:3002" },
"production": { "origin": "https://search.my-app.com" }
}
}
}
}// shell/app/layout.tsx - 서버 컴포넌트에서 Remote 앱 컴포즈
import { Suspense } from 'react';
import { CheckoutWidget } from 'checkout/CheckoutWidget';
import { SearchBar } from 'search/SearchBar';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<header>
<Suspense fallback={<div>검색 로딩 중...</div>}>
<SearchBar />
</Suspense>
</header>
<main>{children}</main>
<Suspense fallback={<div>결제 위젯 로딩 중...</div>}>
<CheckoutWidget />
</Suspense>
</body>
</html>
);
}Suspense is required. When Remote apps are composed as Server Components, they must be wrapped with
<Suspense>for streaming. Omitting it can lead to an unexpected problem where a delay in loading a Remote app blocks the entire page render.
The strength of this approach is that since each MFE is composed as a Server Component, you can achieve a fast First Contentful Paint without downloading a client bundle.
Example 3: Inter-App Communication — Loosely Connected via CustomEvent
You've solved the runtime integration — but how do apps talk to each other? Directly importing shared state tends to create tight coupling. You can implement an event bus using only browser standard APIs, with no framework dependency.
// packages/utils/src/eventBus.ts
type EventMap = {
'cart:item-added': { productId: string; quantity: number };
'auth:logged-in': { userId: string; token: string };
'checkout:completed': { orderId: string };
};
export const eventBus = {
emit<K extends keyof EventMap>(event: K, detail: EventMap[K]) {
window.dispatchEvent(new CustomEvent(event, { detail }));
},
on<K extends keyof EventMap>(
event: K,
handler: (detail: EventMap[K]) => void
) {
const listener = (e: Event) => handler((e as CustomEvent).detail);
window.addEventListener(event, listener);
return () => window.removeEventListener(event, listener); // cleanup 반환
},
};// checkout/src/CheckoutWidget.tsx — 이벤트 발행 측
import { eventBus } from 'utils/eventBus';
async function handleOrderComplete(orderId: string) {
const response = await fetch(`/api/orders/${orderId}/confirm`, { method: 'POST' });
if (!response.ok) throw new Error('주문 처리 실패');
// 결제 MFE는 장바구니 MFE를 직접 알 필요가 없습니다
eventBus.emit('checkout:completed', { orderId });
}// search/src/SearchBar.tsx — 이벤트 구독 측 (React 컴포넌트)
import { useEffect } from 'react';
import { eventBus } from 'utils/eventBus';
function SearchBar() {
useEffect(() => {
const cleanup = eventBus.on('checkout:completed', ({ orderId }) => {
console.log(`주문 ${orderId} 완료 — 검색 히스토리 초기화`);
});
return cleanup; // 컴포넌트 언마운트 시 리스너 자동 해제
}, []);
// ...
}By centrally managing event names and payloads with EventMap for type safety, it also doubles as an interface contract between teams. "Where can I see what events the checkout MFE emits?" — this single file answers that question.
Pros and Cons Analysis
Advantages
| Item | Details |
|---|---|
| Independent deployment | Each team has its own CI/CD pipeline, allowing deployment without being affected by other teams' schedules |
| Technology stack freedom | Teams can mix different frameworks — React, Vue, Svelte, etc. — for their respective apps |
| Fault isolation | Even if the checkout MFE goes down, search and the main page continue to function normally |
| Team autonomy | Granting full ownership per domain speeds up decision-making |
| Incremental migration | Legacy monolithic apps can be replaced part by part without a full rewrite |
Disadvantages and Caveats
Honestly, while filling out the pros and cons table, the "UX fragmentation" item stung the most. Different font weights across teams, subtly different button animation timings — users don't just let these things slide.
| Item | Details | Mitigation |
|---|---|---|
| Bundle duplication | Misconfigured sharing can load multiple versions of React, with real-world cases of an average 15% bundle size increase | shared: { singleton: true } setting + version pinning |
| Operational complexity | CI/CD, monitoring, and log tracing for dozens of apps must each be managed separately | Dedicate a platform team, introduce centralized observability tools (e.g., OpenTelemetry) |
| UX fragmentation | When teams are separated, design system adherence, fonts, and animation timing can diverge across teams | Enforce a shared design system package, establish a platform team review process |
| Complex E2E testing | Even with individual MFE unit tests passing, full-flow integration tests are needed separately | Set up a dedicated Playwright-based integration test pipeline |
Platform Team: In an MFE environment, a team dedicated to managing shared infrastructure, the design system, and deployment pipelines so that individual domain teams can focus on business logic. It's a common prerequisite among companies that have successfully operated MFE. If forming a dedicated team is difficult, at minimum you need 1–2 members who can own CI/CD and the design system before you can get started.
The Most Common Mistakes in Practice
- Adopting MFE with only 2–3 teams — The complexity cost far outweighs the benefits of independent deployment. It's appropriate to consider adoption when you have 5 or more teams and the need for weekly independent deployments.
- Starting without
sharedconfiguration — It's easy to think you can fix it later, but React instance conflicts are only discovered at runtime and are quite tricky to debug. Settingsingleton: trueand specifying version ranges from the start will save you a lot of pain later. - Creating tight coupling via direct cross-app imports — Once
checkoutstarts directly importing internal modules fromsearch, the value of separation is lost. It's recommended to maintain interface contracts through EventBus or shared packages.
Closing Thoughts
It's worth keeping in mind that micro frontends are an architecture that solves "team scale and deployment independence problems" more than "technical problems." If you have large teams and clear domain boundaries, Module Federation 2.0 has already reached a maturity level that is fully ready for production use.
Here are 3 steps you can start with right now.
- Check your adoption fit first. Review your current team count, weekly deployment frequency, and clarity of domain boundaries, and verify that three conditions hold: "5 or more teams + need for weekly independent deployments + ability to dedicate someone to shared infrastructure." Even if forming a dedicated platform team is difficult, the starting point is whether you have at least 1–2 members who can own CI/CD and the design system.
- Start with a small POC. Run
pnpm create mf-appto generate a Module Federation 2.0 example, and try experiencing runtime integration working with just one Shell and one Remote. You'll get a feel for it in a day. - Set up the monorepo + shared package structure first. Use Turborepo to create the skeleton of
apps/shell,apps/[domain], andpackages/design-system, and define shared components and event bus type contracts upfront — it'll make things much smoother when the team grows later.
Next article: Module Federation 2.0 deep dive — how to deploy Remote apps to a CDN in a Vite/Rspack environment and configure version rollbacks and A/B testing
References
- Micro Frontend Architecture: A Full 2026 Guide | ELITEX
- Module Federation 2.0 Reaches Stable Release | InfoQ (2026.04)
- Micro-Frontends 2026: Module Federation 3.0 & Native ESM Federation | WeskillBlog
- 대규모 프론트엔드 아키텍처의 새로운 패러다임 | 올리브영 Tech Blog
- 마이크로 프론트엔드의 이해 및 구현 | AWS 권장 가이드
- Micro-Frontends: Are They Still Worth It in 2025? | Feature-Sliced Design
- Micro-Frontend Netflix Case Study | DEV Community
- Building micro-frontends with webpack's Module Federation | LogRocket Blog
- The Truth Behind Micro Frontends: Insights from Real Case Studies | Bitovi
- 5 Pitfalls of Using Micro Frontends and How to Avoid Them | SitePoint
- Module Federation official docs | module-federation.io
- Micro Frontends with Native Federation | DEV Community