Micro-Frontends in 2025: Breaking Down Monolithic UIs
Learn how micro-frontend architecture enables teams to build scalable, independent frontend applications that work together seamlessly.
Micro-Frontends in 2025: Breaking Down Monolithic UIs
As applications grow, monolithic frontends become harder to maintain, test, and deploy. Micro-frontends offer a solution by decomposing UIs into smaller, independent applications that can be developed and deployed separately.
What Are Micro-Frontends?
Micro-frontends extend the microservices concept to frontend development. Instead of one large application, you have multiple smaller applications that:
- Are developed independently
- Can use different frameworks
- Are deployed separately
- Compose into a single user experience
Why Micro-Frontends?
1. Team Autonomy
Each team owns their domain end-to-end:
Team A: Product Catalog (React)
Team B: Shopping Cart (Vue)
Team C: Checkout (Svelte)
Team D: User Profile (Angular)
Teams can choose their tech stack and release independently.
2. Incremental Upgrades
Migrate from old to new technology gradually:
Legacy App (Angular 1.x) β New App (React)
One micro-frontend at a time
3. Independent Deployment
Deploy changes without coordinating with other teams:
# Team A deploys product catalog
npm run deploy:catalog
# Doesn't affect Team B's cart or Team C's checkout
4. Better Scalability
Scale teams and code independently:
Large e-commerce app:
βββ catalog/ (30 developers)
βββ cart/ (10 developers)
βββ checkout/ (15 developers)
βββ profile/ (8 developers)
βββ admin/ (12 developers)
Implementation Approaches
1. Build-Time Integration
Combine applications during build:
{
"dependencies": {
"@company/product-catalog": "^1.2.0",
"@company/shopping-cart": "^2.1.0"
}
}
import ProductCatalog from '@company/product-catalog';
import ShoppingCart from '@company/shopping-cart';
function App() {
return (
<>
<ProductCatalog />
<ShoppingCart />
</>
);
}
Pros: Simple, type-safe Cons: Requires rebuilding main app, tight coupling
2. Run-Time Integration via JavaScript
Load micro-frontends dynamically:
// Container app
const loadMicroFrontend = async (name, host) => {
const script = document.createElement('script');
script.src = `${host}/bundle.js`;
script.onload = () => {
window[name].mount('#app');
};
document.head.appendChild(script);
};
loadMicroFrontend('ProductCatalog', 'https://catalog.example.com');
Pros: True independence, dynamic loading Cons: Complex state sharing, no type safety
3. Run-Time Integration via Web Components
Use web standards:
// Product catalog micro-frontend
class ProductCatalog extends HTMLElement {
connectedCallback() {
this.innerHTML = '<h1>Products</h1>';
// Render your app
}
}
customElements.define('product-catalog', ProductCatalog);
<!-- Container app -->
<product-catalog></product-catalog>
<shopping-cart></shopping-cart>
Pros: Framework-agnostic, standard API Cons: Browser support, styling encapsulation challenges
4. Server-Side Composition (SSI/ESI)
Compose HTML on the server:
# Nginx config
location /shop {
ssi on;
# Compose fragments from different services
<!--# include virtual="/catalog/fragment" -->
<!--# include virtual="/cart/fragment" -->
}
Pros: SEO-friendly, progressive enhancement Cons: Server complexity, caching challenges
5. Module Federation (Webpack 5)
Share code and dependencies at runtime:
// Catalog app webpack.config.js
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'catalog',
filename: 'remoteEntry.js',
exposes: {
'./ProductList': './src/ProductList',
},
shared: ['react', 'react-dom'],
}),
],
};
// Container app
const ProductList = lazy(() => import('catalog/ProductList'));
function App() {
return (
<Suspense fallback="Loading...">
<ProductList />
</Suspense>
);
}
Pros: Powerful, optimized bundles, shared dependencies Cons: Webpack-specific, complex configuration
Communication Patterns
1. Custom Events
Simple, decoupled communication:
// Shopping cart emits event
window.dispatchEvent(new CustomEvent('cart:updated', {
detail: { itemCount: 3 }
}));
// Header listens
window.addEventListener('cart:updated', (e) => {
updateBadge(e.detail.itemCount);
});
2. Global State Store
Shared state across micro-frontends:
// Shared store
class GlobalStore {
constructor() {
this.state = { user: null, cart: [] };
this.listeners = [];
}
subscribe(listener) {
this.listeners.push(listener);
}
setState(updates) {
this.state = { ...this.state, ...updates };
this.listeners.forEach(l => l(this.state));
}
}
window.__GLOBAL_STORE__ = new GlobalStore();
3. URL State
Share state via URL parameters:
// Catalog sets filter
history.pushState({}, '', '/shop?category=electronics');
// Cart reads filter
const params = new URLSearchParams(location.search);
const category = params.get('category');
4. Props/Attributes
Pass data through component props:
<product-catalog user-id="123" locale="en-US"></product-catalog>
class ProductCatalog extends HTMLElement {
static observedAttributes = ['user-id', 'locale'];
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'user-id') {
this.loadUserPreferences(newValue);
}
}
}
Shared Dependencies
1. Externalize Common Libraries
Avoid loading React multiple times:
// webpack.config.js
module.exports = {
externals: {
react: 'React',
'react-dom': 'ReactDOM',
},
};
<!-- Load shared libraries once -->
<script src="https://cdn.example.com/react@18.js"></script>
<script src="https://cdn.example.com/react-dom@18.js"></script>
2. Module Federation Sharing
Automatically share dependencies:
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true },
}
3. Import Maps
Browser-native module sharing:
<script type="importmap">
{
"imports": {
"react": "https://cdn.example.com/react@18.js",
"react-dom": "https://cdn.example.com/react-dom@18.js"
}
}
</script>
Styling Strategies
1. CSS Isolation
Use unique prefixes or CSS modules:
/* Product catalog */
.catalog__header { /* ... */ }
.catalog__product-card { /* ... */ }
/* Shopping cart */
.cart__header { /* ... */ }
.cart__item { /* ... */ }
2. Shadow DOM
True style encapsulation:
class ProductCard extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.card { border: 1px solid #ccc; }
</style>
<div class="card">...</div>
`;
}
}
3. CSS-in-JS
Scoped styles automatically:
import styled from 'styled-components';
const Card = styled.div`
border: 1px solid #ccc;
/* Automatically scoped */
`;
4. Design System
Share common components and styles:
import { Button, Card, theme } from '@company/design-system';
function ProductCard() {
return (
<Card>
<Button primary>Add to Cart</Button>
</Card>
);
}
Routing Strategies
1. Path-Based Routing
Each micro-frontend owns a path:
/products/** β Product Catalog
/cart/** β Shopping Cart
/checkout/** β Checkout
/profile/** β User Profile
// Container app router
switch (window.location.pathname) {
case /^\/products/:
loadMicroFrontend('catalog');
break;
case /^\/cart/:
loadMicroFrontend('cart');
break;
}
2. Fragment-Based Routing
Multiple micro-frontends per page:
/shop β Container loads multiple fragments
βββ Header (navigation micro-frontend)
βββ Sidebar (filter micro-frontend)
βββ Main (product listing micro-frontend)
Real-World Examples
Spotify
Desktop app structure:
βββ Container (Electron shell)
βββ Browse (React)
βββ Search (React)
βββ Library (Preact)
βββ Player (Web Components)
βββ Playlist (Vue)
IKEA
E-commerce platform:
βββ Product Search (Angular)
βββ Product Details (React)
βββ Shopping Cart (Vue)
βββ Checkout (Svelte)
βββ My Account (Angular)
Challenges and Solutions
1. Performance Overhead
Problem: Loading multiple frameworks
Solutions:
- Share common dependencies
- Use lightweight frameworks (Preact, Svelte)
- Lazy load micro-frontends
- Server-side rendering
2. Consistency
Problem: Inconsistent UX across teams
Solutions:
- Shared design system
- Design tokens
- Regular cross-team reviews
- UX guidelines
3. Communication Complexity
Problem: Coordinating state across apps
Solutions:
- Keep micro-frontends loosely coupled
- Use event-driven architecture
- Minimize shared state
- Clear contracts/APIs
4. Testing
Problem: Integration testing across apps
Solutions:
- Contract testing
- End-to-end tests for critical flows
- Visual regression testing
- Staging environments
Best Practices
1. Start Small
Don't split everything immediately:
β Good: Split by business domain (catalog, cart, checkout)
β Bad: Split every component into micro-frontend
2. Clear Boundaries
Define ownership clearly:
Team Catalog owns:
- Product search
- Product listings
- Product details
- Product reviews
Team Cart owns:
- Shopping cart
- Cart persistence
- Cart recommendations
3. Shared Nothing (Mostly)
Minimize sharing to reduce coupling:
Shared:
- Design system
- Authentication
- Common utilities
Not Shared:
- Business logic
- Domain models
- Internal state
4. Graceful Degradation
Handle micro-frontend failures:
try {
await loadMicroFrontend('catalog');
} catch (error) {
// Show fallback UI
document.getElementById('app').innerHTML = `
<div>Product catalog temporarily unavailable</div>
`;
}
When to Use Micro-Frontends
Good fit:
- Large applications with multiple teams
- Different update cadences for features
- Complex domains with clear boundaries
- Legacy migration projects
Not a good fit:
- Small teams or applications
- Simple applications with few features
- When team coordination is easy
- Tight integration requirements
Conclusion
Micro-frontends offer a powerful pattern for scaling frontend development, but they come with complexity. The key is understanding your organization's needs and choosing the right level of independence.
Start with clear boundaries, simple communication patterns, and gradually increase sophistication as needed. Micro-frontends are a tool, not a goalβuse them when they solve real problems.
Is your application ready for micro-frontends?
Jordan Patel
Web Developer & Technology Enthusiast