Sergi Jajanidze
Selected Work

High-traffic consumer public site

Module Federation micro-frontends

Split a web-component SPA serving two product lines into a shell and two independently deployed remotes, sharing exactly one copy of the framework runtime. Federation emits native ES modules, so remotes load through the browser's own import(), and routes moved to their owning remote one content type at a time.

The problem

A consumer bank's public site — the product and marketing surface, not the authenticated app — served retail and business customers from one web-component SPA: one repo, one build, one deploy queue. A retail change waited on business QA, the bundle only grew, and a design-system upgrade was a whole-app event.

The goal: three independently deployed apps that still behave as one SPA on one origin, sharing exactly one instance of the framework runtime.

The architecture

Diagram
┌──────────────────── shell (host) ────────────────────┐
│  chrome: header · footer · router · search           │
│  consumes:  retail/entry      business/entry         │
│  exposes:   common            (common-entry.mjs)     │
│             ui-elements       (ui-entry.mjs)         │
└───────────────┬───────────────────────┬──────────────┘
                │ import()              │ import()
       ┌────────┴─────────┐   ┌─────────┴──────────┐
       │ retail (remote)  │   │ business (remote)  │
       │ retail-entry.mjs │   │ business-entry.mjs │
       └────────┬─────────┘   └─────────┬──────────┘
                └────── import map ─────┘
                            ▼
        framework runtime + the design-system packages
       resolved from CDN — one copy, shared by all three

The shell owns the chrome and routing, consumes both product remotes, and exposes its own shared layer back to them. The framework and design system sit outside every bundle and are resolved once by the browser through an import map.

Key decisions

Module Federation, not iframes or a runtime orchestrator

The shared design system is built on custom elements, and customElements is one registry per window. A second copy of the framework doesn't get its own namespace — it collides. A repeated define() throws NotSupportedError, and where tags don't collide you get two ReactiveElement base classes, so instanceof fails across the boundary and updates run on two independent queues. Any model that duplicates the runtime is out.

Federation with library: { type: 'module' } and experiments.outputModule emits real ES modules. Remotes load through the browser's native import() — no custom loader, no globals — and the shell's route-level code splitting keeps working unchanged.

The shell is both host and remote

One compilation runs the federation plugin three times: once as the host consuming the two remotes, and twice as containers publishing the shared component layer and the design-element package. The code the shell compiles for itself is bit-for-bit the code the remotes import — no separate publish step, no package version to keep in sync across three repos.

The exposes maps are generated by walking the source tree at build time, skipping tests, demos and config, and registering each module as both ./x and ./x.js, since remotes import it both ways. That is hundreds of keys nobody maintains by hand, and a contract that can't drift from the code it describes.

Import maps for singletons, not Federation's shared scope

Shared-scope negotiation is a runtime mechanism with a build-time contract — a lot of surface for a guarantee as absolute as one copy of the element base class. Instead, the framework and design-system packages are externals that compile to bare specifiers, and one import map in the shell's document resolves them to immutable CDN URLs. That gives one runtime instance by construction, cached across all three apps and across deploys. The mechanics are in the design system case study.

Routing is a component map each remote owns

Pages are keyed by CMS content type. Each remote exposes a map of contentType → { name, action: () => import(...) }, and the shell composes them: the retail map merges into its own namespace, and the business map is selected by top-level path. A team adds or removes a page by shipping its own remote; the shell changes only when the chrome or the routing contract does.

The migration ran on this contract. The shell shipped first, wrapping the monolith's existing pages, then routes moved to their owning remote one content type at a time — each move a one-line change with an instant revert. Crawlers received pre-rendered HTML throughout, so no move carried an SEO regression. No big-bang cutover, no long-lived branch.

Same origin in production, a self-reloading loop in development

In production, remotes resolve to same-origin paths, so there is no CORS and no cross-domain CSP surface. Locally they resolve to env-configured URLs, a watch plugin pings the shell after each remote rebuild, and watch builds skip chunk splitting and tree-shaking so the three-process loop stays fast.

Two caching tiers, and a CI gate on duplicates

Remote entry files keep a stable URL and are revalidated; everything behind them is content-hashed and immutable. Getting that backwards serves a cached entry pointing at chunks a later deploy already deleted. For the same reason the service worker never precaches application JS or CSS: a manifest baked at shell build time would pin users to a module graph two remote deploys stale.

A design system is a tree of packages, so a lockfile can quietly resolve two versions of one element package — reintroducing duplicate registration at install time. A CI step fails the build when any design-system package resolves to more than one version. A small check, and the highest-value one in the repo.

Impact

  • Release conflicts eliminated: retail and business ship on independent cadences across two teams, and neither blocks the other's QA.
  • Framework and design-system code is downloaded once per user, shared across all three apps, and stays cached across deploys.
  • Design-system upgrades are a version bump in one file, with no application rebuilds.
  • The migration ran route by route, each with its own revert, behind a routing contract instead of a branch.