Web Application Microfrontend Architecture Development
Microfrontends are an organizational pattern, not a technology. Essence: a large frontend application is split into independent parts, each owned by separate teams. Each part is developed, tested, and deployed independently.
This is justified when 4+ teams work on one application, when a monolith has become a deployment bottleneck, or when different parts of the application develop at fundamentally different paces.
What's included in the work
Analysis of the domain and team boundaries, selection of integration approach, shell-architecture design, setup of shared dependencies, design system, communication between microfrontends, CI/CD strategy, documentation.
Integration approaches — comparison
| Approach | Build-time | Run-time | Isolation | Complexity |
|---|---|---|---|---|
| NPM packages | Yes | No | No | Low |
| Module Federation | No | Yes | Partial | Medium |
| iframes | No | Yes | Full | Low |
| Web Components | No | Yes | CSS | Medium |
| Single-SPA | No | Yes | Partial | High |
For most projects, Module Federation (if everything is React/Vue) or Single-SPA (if teams use different frameworks) are optimal.
Step 1 — Analysis of domain boundaries
A microfrontend boundary is a bounded context boundary in the business domain, not technical convenience:
E-commerce platform:
├── Catalog Team → /products, /categories, /search
├── Cart Team → /cart, mini-cart widget
├── Checkout Team → /checkout, /payment
├── Account Team → /profile, /orders, /addresses
└── Platform Team → shell, auth, design system, analytics
Poor division: by technical stack (header/sidebar/content), by UI components, by layers (API/state/view).
Step 2 — Shell application
Shell is a thin wrapper without business logic. Responsible only for:
- top-level routing
- loading microfrontends
- shared services (auth, analytics)
- navigation and layout
// apps/shell/src/App.tsx
import React, { Suspense, lazy } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { Shell } from './components/Shell'
import { AuthGuard } from './guards/AuthGuard'
// lazy — load only what's needed
const CatalogApp = lazy(() => import('catalog/App'))
const CheckoutApp = lazy(() => import('checkout/App'))
const AccountApp = lazy(() => import('account/App'))
export function App() {
return (
<Shell>
<Routes>
<Route path="/" element={<Navigate to="/products" replace />} />
<Route
path="/products/*"
element={
<Suspense fallback={<AppSkeleton name="Catalog" />}>
<CatalogApp />
</Suspense>
}
/>
<Route
path="/checkout/*"
element={
<AuthGuard>
<Suspense fallback={<AppSkeleton name="Checkout" />}>
<CheckoutApp />
</Suspense>
</AuthGuard>
}
/>
<Route
path="/account/*"
element={
<AuthGuard>
<Suspense fallback={<AppSkeleton name="Account" />}>
<AccountApp />
</Suspense>
</AuthGuard>
}
/>
</Routes>
</Shell>
)
}
Step 3 — Shared contracts
Microfrontends communicate through contracts. Contracts need to be versioned and maintain backward compatibility:
// packages/contracts/src/events.ts
// Typed events — published as npm package
export interface CartEvents {
'cart:item-added': { productId: string; quantity: number; price: number }
'cart:item-removed': { productId: string }
'cart:cleared': Record<string, never>
'cart:checkout-started': { cartTotal: number; itemCount: number }
}
export interface AuthEvents {
'auth:login': { userId: string; roles: string[] }
'auth:logout': Record<string, never>
'auth:token-refreshed': { expiresAt: number }
}
// Typed event bus
type AllEvents = CartEvents & AuthEvents
export class EventBus {
private emitter = new EventTarget()
emit<K extends keyof AllEvents>(event: K, detail: AllEvents[K]) {
this.emitter.dispatchEvent(new CustomEvent(event as string, { detail }))
}
on<K extends keyof AllEvents>(event: K, handler: (detail: AllEvents[K]) => void) {
const listener = (e: Event) => handler((e as CustomEvent).detail)
this.emitter.addEventListener(event as string, listener)
return () => this.emitter.removeEventListener(event as string, listener)
}
}
export const eventBus = new EventBus()
Step 4 — Shared dependencies and design system
The design system is a separate package that all microfrontends receive as an npm dependency:
packages/
ui/ — components, tokens, icons
src/
components/
tokens/
icons/
package.json
contracts/ — event and interface types
shared-config/ — eslint, tsconfig, prettier base configs
In Module Federation, make the UI package singleton: true — all microfrontends use one version:
// in each webpack.config.js / vite.config.ts
shared: {
'@company/ui': { singleton: true, requiredVersion: '^2.0.0' },
react: { singleton: true },
'react-dom': { singleton: true },
}
Step 5 — Routing strategy
Two approaches to routing:
Centralized (shell owns top-level routes):
shell: /products → loads CatalogApp
catalog: /products/:id, /products?search=
Delegated (each microfrontend owns its own routes):
shell: /* → passes to CatalogApp or CheckoutApp
catalog: handles /products/*, /categories/*
When using React Router, I recommend centralized: shell defines top-level /products/*, inside CatalogApp — its own nested Routes.
Step 6 — Authentication
Auth logic is in shell or in a separate auth-microfrontend. Others only receive the authorization fact:
// packages/auth-client/src/index.ts
export interface AuthContext {
user: User | null
token: string | null
isAuthenticated: boolean
login: (credentials: Credentials) => Promise<void>
logout: () => void
hasPermission: (permission: string) => boolean
}
// AuthProvider in shell
export function AuthProvider({ children }: { children: React.ReactNode }) {
// token storage, refresh, logout on 401 logic
const auth = useAuthState()
return (
<AuthContext.Provider value={auth}>
{children}
</AuthContext.Provider>
)
}
// In microfrontend — only useAuth()
// import { useAuth } from '@company/auth-client'
// const { user, hasPermission } = useAuth()
Step 7 — Degradation handling
Remote can be unavailable. Shell should degrade gracefully:
function withRemoteFallback<P extends object>(
remoteLoader: () => Promise<{ default: React.ComponentType<P> }>,
FallbackComponent: React.ComponentType
) {
const Remote = lazy(remoteLoader)
return function RemoteWithFallback(props: P) {
return (
<ErrorBoundary
onError={(error) => {
monitoring.captureException(error, { tags: { type: 'remote_load_failure' } })
}}
fallback={<FallbackComponent />}
>
<Suspense fallback={<Spinner />}>
<Remote {...props} />
</Suspense>
</ErrorBoundary>
)
}
}
const CatalogApp = withRemoteFallback(
() => import('catalog/App'),
() => <ServiceUnavailable name="Catalog" />
)
Step 8 — CI/CD strategy
monorepo (or polyrepo):
apps/catalog/ → pipeline → CDN catalog.example.com
apps/checkout/ → pipeline → CDN checkout.example.com
apps/shell/ → pipeline → CDN example.com
shell stores remoteEntry URL in config received from server:
/api/mf-config → { catalog: "https://catalog.example.com/...", ... }
# .github/workflows/catalog-deploy.yml
name: Catalog Deploy
on:
push:
branches: [main]
paths: ['apps/catalog/**', 'packages/ui/**']
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cd apps/catalog && npm ci && npm test && npm run build
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- name: Deploy to S3
run: aws s3 sync apps/catalog/dist s3://$CATALOG_BUCKET --delete
- name: Notify shell of new version
run: |
curl -X POST $SHELL_API/mf-config \
-H "Authorization: Bearer $DEPLOY_TOKEN" \
-d '{"catalog": "https://catalog.example.com/assets/remoteEntry.js"}'
Microfrontend monitoring
// Track loading of each remote
window.addEventListener('unhandledrejection', (e) => {
if (e.reason?.message?.includes('Loading chunk')) {
monitoring.track('remote_load_failure', {
error: e.reason.message,
remote: detectRemoteFromStack(e.reason.stack),
})
}
})
// Web Vitals for each microfrontend
import { onLCP, onFID, onCLS } from 'web-vitals'
onLCP((metric) => analytics.track('LCP', { value: metric.value, remote: 'catalog' }))
Common mistakes during transition
Too fine-grained division — a microfrontend from 3 components is not justified by operational overhead.
No contracts — teams start depending on each other's internal implementations.
Unsynchronized versions of shared dependencies — two React on page → two VDOM, hook bugs, bloated bundle.
Direct imports between microfrontends — violates isolation and makes independent deployment impossible.
What we do
We conduct domain boundary analysis with product/engineering teams, select the integration approach, design shell and contracts package, set up Module Federation or Single-SPA, organize CI/CD with independent deployment, configure remote-loading monitoring.
Timeline: 10–20 days — architecture, shell implementation, setup 2–3 microfrontends, CI/CD. Further logic migration — within separate tasks.







