Web Components for micro-frontend architecture

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

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.