Implementing Web Components for Microfrontend Architecture
Microfrontends solve one specific problem: how to allow multiple teams to independently deploy parts of a single interface without turning the build into a monolith. Web Components are a native browser mechanism that provides technological isolation without a single framework dictating everything.
Why Web Components instead of Module Federation
Module Federation (Webpack 5) is a powerful tool, but it locks all teams into one build system. If one team wants Vite, another wants Rollup, and a third is writing in Svelte, compromises become inevitable.
Web Components work at the browser level. customElements.define('order-cart', OrderCartElement) and this component can be used by any host without knowing anything about React, Vue, or vanilla JS inside it.
The limitations are real too: Shadow DOM complicates global styles, event communication requires discipline, and SSR is a separate pain (though Declarative Shadow DOM in Chrome 90+ partially addresses it).
Project Structure
A typical scheme: shell application (host) + N microfrontends, each publishing one or more custom elements.
monorepo/
├── shell/ # host, routing, layout
├── mfe-catalog/ # product catalog
├── mfe-cart/ # cart and checkout
├── mfe-account/ # user account
└── shared/
├── design-tokens/ # CSS variables, common tokens
└── events/ # typed events (TypeScript)
Each mfe-* builds into a single JS file and is published to a CDN or internal npm. Shell loads them via <script type="module">.
Implementing a Basic Web Component
// mfe-cart/src/CartWidget.ts
export class CartWidget extends HTMLElement {
private shadow: ShadowRoot;
private _items: CartItem[] = [];
static get observedAttributes() {
return ['user-id', 'currency'];
}
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
this.loadItems();
// listen for events from other MFEs
window.addEventListener('product:added', this.handleProductAdded);
}
disconnectedCallback() {
window.removeEventListener('product:added', this.handleProductAdded);
}
attributeChangedCallback(name: string, _old: string, next: string) {
if (name === 'user-id' && next) {
this.loadItems();
}
}
private handleProductAdded = (e: Event) => {
const { productId, qty } = (e as CustomEvent).detail;
this.addToCart(productId, qty);
};
private async loadItems() {
const userId = this.getAttribute('user-id');
if (!userId) return;
const res = await fetch(`/api/cart/${userId}`);
this._items = await res.json();
this.render();
}
private async addToCart(productId: string, qty: number) {
await fetch('/api/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, qty }),
});
await this.loadItems();
// notify shell and other MFEs
this.dispatchEvent(new CustomEvent('cart:updated', {
detail: { count: this._items.length },
bubbles: true,
composed: true, // penetrates Shadow DOM
}));
}
private render() {
this.shadow.innerHTML = `
<style>
:host {
display: block;
font-family: var(--font-sans, system-ui);
}
.cart-count {
background: var(--color-accent, #e53e3e);
color: white;
border-radius: 50%;
padding: 2px 6px;
font-size: 12px;
}
</style>
<button part="trigger">
Cart
<span class="cart-count">${this._items.length}</span>
</button>
`;
this.shadow.querySelector('button')
?.addEventListener('click', () => this.openCart());
}
private openCart() {
window.dispatchEvent(new CustomEvent('cart:open'));
}
}
customElements.define('cart-widget', CartWidget);
Inter-Component Communication
Direct calls between MFEs are an anti-pattern. An event bus is needed. The simplest approach is window with typing:
// shared/events/index.ts
export type AppEvents = {
'product:added': { productId: string; qty: number };
'cart:updated': { count: number };
'user:authenticated': { userId: string; token: string };
'navigation:requested': { path: string };
};
type EventMap = {
[K in keyof AppEvents]: CustomEvent<AppEvents[K]>;
};
declare global {
interface WindowEventMap extends EventMap {}
}
export function emit<K extends keyof AppEvents>(
type: K,
detail: AppEvents[K],
target: EventTarget = window
) {
target.dispatchEvent(new CustomEvent(type, { detail, bubbles: true }));
}
export function on<K extends keyof AppEvents>(
type: K,
handler: (detail: AppEvents[K]) => void,
target: EventTarget = window
) {
const listener = (e: Event) => handler((e as CustomEvent<AppEvents[K]>).detail);
target.addEventListener(type, listener);
return () => target.removeEventListener(type, listener);
}
Shell Application and Dynamic Loading
The shell doesn't know about MFE internals, only their URLs and public API (attributes and events).
// shell/src/registry.ts
interface MFEManifest {
name: string;
url: string;
elements: string[];
}
const manifest: MFEManifest[] = [
{
name: 'cart',
url: 'https://cdn.example.com/[email protected]/index.js',
elements: ['cart-widget', 'cart-drawer'],
},
{
name: 'catalog',
url: 'https://cdn.example.com/[email protected]/index.js',
elements: ['product-card', 'product-list', 'product-filter'],
},
];
export async function loadMFE(name: string): Promise<void> {
const entry = manifest.find(m => m.name === name);
if (!entry) throw new Error(`Unknown MFE: ${name}`);
// check that elements aren't already registered
const alreadyLoaded = entry.elements.every(
el => customElements.get(el) !== undefined
);
if (alreadyLoaded) return;
await import(/* @vite-ignore */ entry.url);
}
// shell/src/router.ts
import { loadMFE } from './registry';
const routes: Record<string, () => Promise<void>> = {
'/catalog': () => loadMFE('catalog'),
'/cart': () => loadMFE('cart'),
'/account': () => loadMFE('account'),
};
export async function navigate(path: string) {
const loader = routes[path];
if (loader) await loader();
document.querySelector('#app-root')!.innerHTML = getTemplate(path);
history.pushState(null, '', path);
}
Styles: Isolation vs Design System
Shadow DOM isolates styles completely. CSS variables penetrate through, which is the mechanism for the design system:
/* shell/src/global.css — tokens available to all MFEs */
:root {
--color-primary: #1a56db;
--color-accent: #e3a008;
--color-surface: #f9fafb;
--font-sans: 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--radius-md: 8px;
--shadow-sm: 0 1px 3px rgba(0,0,0,.1);
--spacing-unit: 4px;
}
For more complex style transmission (for example, fonts via @font-face), use Constructable Stylesheets:
// shared/design-tokens/stylesheet.ts
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
:host { font-family: var(--font-sans, system-ui); }
* { box-sizing: border-box; }
`);
export const baseStyles = sheet;
// In the component:
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.adoptedStyleSheets = [baseStyles];
}
Build and Versioning
Each MFE builds independently. Example Vite config:
// mfe-cart/vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: {
entry: 'src/index.ts',
formats: ['es'],
fileName: 'index',
},
rollupOptions: {
// React/Vue as external dependencies only if shell provides them
// Otherwise bundle inside — each MFE is self-contained
external: [],
},
target: 'es2020',
},
});
Versioning via semantic tags in CDN URLs. For breaking API changes, use a major version bump and the shell explicitly moves to the new URL. No automatic latest pulling.
Testing
Unit tests for components via @web/test-runner (supports real DOM, unlike jsdom):
// mfe-cart/test/cart-widget.test.ts
import { fixture, html, expect } from '@open-wc/testing';
import '../src/CartWidget';
describe('cart-widget', () => {
it('renders empty cart count', async () => {
const el = await fixture<HTMLElement>(
html`<cart-widget user-id=""></cart-widget>`
);
const count = el.shadowRoot!.querySelector('.cart-count');
expect(count?.textContent).to.equal('0');
});
it('dispatches cart:updated after add', async () => {
const el = await fixture<HTMLElement>(
html`<cart-widget user-id="user-123"></cart-widget>`
);
let eventFired = false;
el.addEventListener('cart:updated', () => { eventFired = true; });
window.dispatchEvent(new CustomEvent('product:added', {
detail: { productId: 'prod-1', qty: 1 }
}));
await new Promise(r => setTimeout(r, 50));
expect(eventFired).to.be.true;
});
});
E2E testing via Playwright — run the shell locally and verify integration of all MFEs together.
Timeline and Phases
A project from scratch for three to four microfrontends takes six to ten weeks:
The first two weeks are for design: define MFE boundaries, event schema, style strategy, and CI/CD for independent deployments.
Weeks three and four are infrastructure: shell, event bus, design tokens, build configs, CDN publishing.
Weeks five through eight are for teams to develop MFEs in parallel.
Weeks nine and ten are for integration testing, load testing (lazy-loading shouldn't cause noticeable delays), and production deployment.
The maturity of this approach directly depends on team discipline with versioning and event contracts. Without formalized shared/events with types and changelogs, microfrontends quickly become a distributed monolith.







