Overview
Micro-frontends apply the microservices decomposition principle to the frontend: each team owns a vertical slice of the UI — from feature to deploy to monitoring — without coordinating with other teams.
The core trade-off:
- Gain: independent deployability, team autonomy, isolated blast radius
- Cost: runtime composition complexity, shared UX consistency burden, debugging across team boundaries
Decomposition strategies:
| Strategy | How | When |
|---|---|---|
| Route-level split | Each app owns URL prefixes | Most common, cleanest boundaries |
| Component-level split | Module Federation remotes | Shared design system, auth widgets |
| Iframe | Each app in an iframe | Legacy integration, hard isolation |
| Web Components | Custom Elements | Framework-agnostic, good for widgets |
A pragmatic default is a route-level split for domain apps plus component-level composition for stable platform capabilities such as design systems and authentication.
Technical Implementation
Shell App (Host) — Route-Based Dispatch
// shell/app/layout.tsx
import { RemoteLoader } from '@/components/RemoteLoader'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<GlobalNav /> {/* from designSystem remote */}
<main>{children}</main>
<GlobalFooter />
</body>
</html>
)
}
// shell/app/product/[...slug]/page.tsx
import dynamic from 'next/dynamic'
const ProductApp = dynamic(
() => import('productRemote/App').catch(() => import('./ProductFallback')),
{ ssr: false }
)
export default function ProductPage() {
return <ProductApp />
}
Domain App — Unaware It's a Remote
// product-app/app/page.tsx — standard Next.js page
// This app runs standalone at :3001 AND as a remote in the shell
export default function ProductHome() {
const { user } = useSession() // from auth remote
return <ProductListing user={user} />
}
Shared State — Session via Auth Remote
// Any domain app or shell component
import { useSession, SessionProvider } from 'auth/SessionProvider'
// Shell wraps everything in SessionProvider
// Domain apps consume via useSession() — no direct auth service calls
export function AddToWishlist({ productId }: { productId: string }) {
const { user, isAuthenticated } = useSession()
if (!isAuthenticated) return <LoginPrompt />
return <button onClick={() => wishlist.add(productId, user.id)}>Save</button>
}
Contract Testing with Pact
// product-app/tests/contracts/designSystem.pact.ts
import { Pact } from '@pact-foundation/pact'
describe('designSystem/Button contract', () => {
it('renders with variant=primary', async () => {
const Button = await import('designSystem/Button')
render(<Button.default variant="primary">Add to Cart</Button.default>)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
Pact publishes this contract to the Pact Broker. The design system's CI verifies it before deploy — prevents breaking the host silently.
Architecture
Challenges
1. Debugging across team boundaries
When a bug crossed shell → domain remote → platform remote, Sentry had 3 separate projects, 3 different deploys, no unified trace. Finding the root cause meant correlating timestamps across dashboards.
Fix: added a x-request-id header in the shell, propagated via React context to all remotes. All Sentry errors tagged with it. DataDog RUM auto-tagged with remote_name via a shared error boundary.
2. Performance: remote waterfall
Early implementation: shell loaded → parsed → discovered remotes → fetched remoteEntry.js for each. Added 200–400ms before any domain content appeared.
Fix:
<!-- Shell _document.tsx: preload critical remotes -->
<link rel="preload" href="${process.env.DS_REMOTE_URL}/remoteEntry.js" as="script" />
<link rel="preload" href="${process.env.AUTH_REMOTE_URL}/remoteEntry.js" as="script" />
Non-critical remotes (analytics) deferred until requestIdleCallback.
3. Consistent UX across teams
Domain teams started writing raw Tailwind classes that diverged from the design system. The product and account pages looked visually different despite using the "same" design language.
Fix: ESLint rule in domain app templates that banned raw color/spacing utilities and required design system components for interactive elements. Enforced at PR time.
Platform Responsibilities
- Define the shell, domain-remote, and platform-remote topology.
- Own routing, global layout, remote loading orchestration, and error boundaries.
- Establish inter-app contracts with explicit state ownership, contract tests, and shared type declarations.
- Measure remote load waterfalls and preload only the dependencies that improve critical rendering.
- Maintain an integration guide that makes onboarding and operational ownership explicit.
References
- Martin Fowler: Micro Frontends — canonical overview
- Webpack Module Federation Docs
- Pact Contract Testing — consumer-driven contracts
- web.dev: Core Web Vitals — measuring the perf cost
- Related: Module Federation Deep Dive