Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Sep 29, 2026·16 min read

Design System Distribution: NPM Topologies, Runtime Federation & Deprecation Governance

Distributing design system components as live runtime singletons is a deployment coupling decision disguised as a convenience. This article maps three distribution topologies — static NPM, Module Federation remote singleton, and hybrid — with a clear recommendation and a deprecation governance workflow using jscodeshift codemods.

Design System Distribution: NPM Topologies, Runtime Federation & Deprecation Governance

Architecture is not about drawing boxes on a whiteboard — it is about enforcing boundary contracts, deterministic caching, and secure data mediation across independent release units. In Part 3, we designed those boundaries inside the design system. Now we face the question that determines whether those boundaries hold in production: how do you get the tokens and components from a centralized repository into fifteen independently deployed applications?
The answer to this question is not a technology choice. It is an organizational commitment. Every distribution topology carries a different blast radius, a different upgrade cadence, and a different failure mode. This article maps three topologies honestly — including the failure scenarios that most design system talks omit — and arrives at the hybrid architecture that is the production standard for multi-team organizations.

1. The Distribution Problem

An e-commerce platform has twelve independently deployed micro-frontends: Product Catalog, Cart, Checkout, User Profile, Search, Recommendations, Order History, Returns, Loyalty, Notifications, Admin, and Analytics Dashboard. Every one of them renders Button, consumes color.brand.primary, and mounts inside the same GlobalNavigation.
This creates three tensions:
TensionDescription
Visual Consistency vs. Team AutonomyThe design team wants to enforce pixel-perfect consistency. Product teams want to ship without waiting for design system approval.
Instant Updates vs. Blast RadiusA bug fix in Button should reach all twelve apps immediately. But a breaking prop change should not crash all twelve simultaneously.
Version Drift vs. Upgrade ParalysisTeams pinned to old versions cause visual inconsistency. But mandatory synchronous upgrades block feature work.
No topology solves all three tensions. The correct question is: which trade-offs can your organization absorb?

2. Topology A — Build-Time NPM Distribution

2.1 How It Works

The design system is published to an npm registry (public or private) as versioned packages:
@company/tokens          v1.4.2   ← design tokens
@company/ui              v3.1.0   ← headless primitives + styled components
Consuming apps declare explicit version ranges in package.json:
json
{
  "dependencies": {
    "@company/tokens": "^1.4.2",
    "@company/ui": "^3.1.0"
  }
}
Each app builds independently. At build time, the declared versions are resolved from the registry and compiled into the app's bundle. No runtime dependency on a shared remote — the tokens and components are fully owned by the app's build.

2.2 The Version Drift Problem

Twelve teams, twelve package.json files. After six months:
apps/product-catalog   → @company/ui@3.1.0
apps/cart              → @company/ui@3.1.0
apps/checkout          → @company/ui@2.8.0   ← stuck on old version
apps/user-profile      → @company/ui@4.0.0   ← early adopter
apps/search            → @company/ui@3.0.5
...
Five different versions of Button render in the same user session. The product looks like a patchwork. The checkout team is blocked because v3.x introduced a breaking change they haven't had time to migrate.

2.3 Automated Drift Remediation with Renovate

Renovate Bot automates the upgrade PR workflow:
json
// renovate.json — at repository root
{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:base"],
  "packageRules": [
    {
      "matchPackageNames": ["@company/tokens", "@company/ui"],
      "groupName": "Design System",
      "automerge": true,
      "automergeType": "pr",
      "requiredStatusChecks": ["visual-regression", "build", "test"]
    }
  ],
  "prConcurrentLimit": 2
}
The requiredStatusChecks list is the key: visual-regression runs Playwright component snapshots against the upgraded version before auto-merging. If a token change shifts button padding by 2px, the visual regression test fails, the PR is not auto-merged, and a human reviews it.
yaml
# .github/workflows/visual-regression.yml
name: Visual Regression

on: [pull_request]

jobs:
  snapshot:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pnpm install
      - run: pnpm build --filter=@company/ui
      - name: Run Playwright Visual Tests
        run: pnpm playwright test --project=chromium
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: visual-diff
          path: test-results/
Pro Tip & Optimization
Set automerge: true only for patch version bumps of token packages. For UI component packages, require human review on any minor or major version bump — prop interface changes can be subtle and visually regression-test-evasive.

2.4 NPM Distribution Failure Modes

Failure ModeTriggerImpact
Version driftTeams don't upgradeVisual inconsistency across apps
Upgrade paralysisMajor breaking changesTeams blocked for weeks
Bundle duplicationEach app bundles its own copyLarger total bundle weight
Stale cachenpm registry cache not invalidatedApps serve old component versions after a critical fix

3. Topology B — Runtime Module Federation Remote Singleton

3.1 How It Works

The design system is exposed as a live Module Federation remote. Apps load the design system at runtime from a CDN URL instead of bundling it at build time:
javascript
// apps/checkout/rspack.config.js
const { ModuleFederationPlugin } = require('@module-federation/enhanced/rspack')

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'checkout',
      remotes: {
        designSystem: 'designSystem@https://cdn.example.com/design-system/remoteEntry.js'
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' }
      }
    })
  ]
}
typescript
// In any component
const { Button } = await import('designSystem/Button')
When the design system team ships a new version, they update the CDN. All twelve apps immediately load the new version on the next user request — no rebuild, no redeployment of any consuming app.

3.2 The Promise and Why It Is Dangerous

The promise is real: instant visual synchronization across all apps, zero coordinated deployments. The danger is equally real.
typescript
// Design system v3.1.0
interface ButtonProps {
  variant: 'primary' | 'secondary' | 'ghost'
  label: string
  onClick: () => void
}

// Design system v3.2.0 — BREAKING PROP CHANGE
interface ButtonProps {
  intent: 'primary' | 'secondary' | 'ghost'  // ← 'variant' renamed to 'intent'
  children: React.ReactNode                   // ← 'label' replaced with children
  onPress: () => void                         // ← 'onClick' renamed to 'onPress'
}
With a runtime singleton remote, this change is deployed to the CDN and simultaneously breaks all twelve apps. Every <Button variant="primary" label="Submit" onClick={...}> across every micro-frontend throws a runtime error. There is no staging rollout, no canary, no per-team opt-in. The blast radius is the entire product.
Performance / Safety Warning
Never federate a full UI component library as a runtime singleton remote. The blast radius of a breaking prop change is total and simultaneous across all consumers. The only safe candidates for runtime federation are components with extremely narrow, stable prop interfaces (e.g., GlobalNavigation with a fixed user prop and a links array) that are explicitly versioned through a manifest.

3.3 When Runtime Federation Is Acceptable

Runtime federation for design system components is acceptable when:
  1. The component's prop interface is narrow and stable (≤ 5 props, no breaking changes in 12+ months).
  2. The component is globally visible (renders in every app — GlobalNavigation, UniversalFooter, CookieBanner).
  3. The deployment uses a manifest (not a hardcoded CDN URL) enabling rollback within seconds.
  4. Visual regression tests run against the remote before it is promoted to the CDN.

4.1 The Architecture

@company/tokens     → Versioned NPM package (zero runtime, build-time)
@company/ui         → Versioned NPM package (headless primitives, build-time)
GlobalNavigation    → Module Federation remote (narrow interface, manifest-driven, runtime)
UniversalFooter     → Module Federation remote (narrow interface, manifest-driven, runtime)
CookieBanner        → Module Federation remote (narrow interface, manifest-driven, runtime)
Layer 1 tokens and Layer 2 headless primitives are distributed statically via versioned NPM. They have zero runtime dependency, so there is no blast radius — a broken token package only affects the teams who choose to upgrade.
The three global chrome widgets — GlobalNavigation, UniversalFooter, CookieBanner — are federated at runtime because they are genuinely global (every app must show the same navigation simultaneously), they have stable, narrow prop interfaces, and instant visual sync on navigation updates is a product requirement.

4.2 The Manifest-Driven Remote

For the federated chrome widgets, use @module-federation/manifest to decouple the CDN URL from the consuming app's build:
json
// https://cdn.example.com/global-nav/mf-manifest.json
{
  "id": "globalNav",
  "name": "globalNav",
  "metaData": {
    "buildInfo": { "buildVersion": "3.4.1" }
  },
  "remoteEntry": {
    "name": "remoteEntry",
    "path": "./",
    "type": "module"
  },
  "exposes": [
    {
      "id": "globalNav:GlobalNavigation",
      "name": "GlobalNavigation",
      "assets": { "js": { "async": ["globalNav-GlobalNavigation.js"] } }
    }
  ]
}
javascript
// apps/checkout/rspack.config.js
new ModuleFederationPlugin({
  remotes: {
    globalNav: {
      type: 'module',
      name: 'globalNav',
      entry: 'https://cdn.example.com/global-nav/mf-manifest.json',
      entryGlobalName: 'globalNav',
      shareScope: 'default'
    }
  }
})
Rolling back a broken GlobalNavigation is now a CDN manifest pointer update — no consuming app needs to be redeployed. The rollback takes 30 seconds.

5. Static Asset, Font & Icon Distribution

5.1 Preventing Duplicate @font-face Requests

css
/* ❌ Every micro-frontend declares its own @font-face */
/* apps/checkout/styles/global.css */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/Inter-Regular.woff2') format('woff2');
  font-weight: 400;
}

/* apps/cart/styles/global.css — identical declaration */
@font-face { ... }

/* Result: browser downloads Inter-Regular.woff2 once per app domain
   if cross-origin, or hits the cache if same-origin */
The correct approach depends on your architecture:
  • Same-origin apps: Host fonts at a shared CDN path (/shared-assets/fonts/). Each app references the same URL. The browser caches once.
  • Cross-origin MFEs: Load fonts once from the App Shell using <link rel="preload">. Remotes that render in the same browser context inherit the already-loaded font faces.

5.2 SVG Icon Distribution

StrategyMechanismBundle Impact
Inline SVG componentsimport { ArrowRight } from '@company/icons'Only imported icons in bundle
SVG sprite sheetOne <svg> with <symbol> elements, referenced by <use href="#icon-arrow">One network request; all icons available
Federated icon remoteIcon components loaded as MF remoteAdds federation overhead for small assets
For most teams, inline SVG components with tree-shaking is the optimal choice — only the icons actually used are included in each app's bundle.
typescript
// packages/icons/src/index.ts — export each icon as a named component
export { ArrowRight } from './ArrowRight'
export { Search } from './Search'
export { User } from './User'
// ...

// apps/checkout — only ArrowRight and User are bundled
import { ArrowRight, User } from '@company/icons'

6. Deprecation Lifecycle & Automated Codemods

6.1 The Three-Phase Lifecycle

Phase 1: Soft Deprecate
  → Add @deprecated JSDoc comment
  → Add ESLint rule warning
  → Consumers see IDE warnings, CI passes

Phase 2: Hard Deprecate
  → Convert ESLint warning to ESLint error
  → CI fails on any use of deprecated component
  → Ship jscodeshift codemod alongside

Phase 3: Removal
  → Remove from package
  → Breaking semver major bump
  → Run codemod in consuming repos before removal

6.2 Phase 1 — ESLint Deprecation Warning

typescript
// packages/ui/src/Button/Button.tsx
/**
 * @deprecated Use `<ActionButton>` instead. Will be removed in v5.0.0.
 * @see https://design.company.com/migration/button-to-action-button
 */
export function Button({ variant, label, onClick }: ButtonProps) { ... }
json
// packages/ui/.eslintrc.json
{
  "rules": {
    "deprecation/deprecation": "warn"
  }
}

6.3 Phase 2 — Automated Codemod with jscodeshift

Ship a codemod alongside the hard deprecation so teams can migrate in seconds:
javascript
// packages/ui/codemods/button-to-action-button.js
module.exports = function transform(fileInfo, api) {
  const j = api.jscodeshift
  const root = j(fileInfo.source)

  // Replace: import { Button } from '@company/ui'
  // With:    import { ActionButton } from '@company/ui'
  root
    .find(j.ImportDeclaration, { source: { value: '@company/ui' } })
    .find(j.ImportSpecifier, { imported: { name: 'Button' } })
    .forEach(path => {
      path.node.imported.name = 'ActionButton'
      path.node.local.name = 'ActionButton'
    })

  // Replace: <Button variant="primary" label="Submit" onClick={fn} />
  // With:    <ActionButton intent="primary" onPress={fn}>Submit</ActionButton>
  root
    .findJSXElements('Button')
    .forEach(path => {
      // Rename element
      path.node.openingElement.name.name = 'ActionButton'
      if (path.node.closingElement) {
        path.node.closingElement.name.name = 'ActionButton'
      }

      const attrs = path.node.openingElement.attributes
      
      // Rename variant → intent
      const variantAttr = attrs.find(a => a.name?.name === 'variant')
      if (variantAttr) variantAttr.name.name = 'intent'
      
      // Extract label → children
      const labelAttr = attrs.find(a => a.name?.name === 'label')
      if (labelAttr) {
        const labelValue = labelAttr.value
        attrs.splice(attrs.indexOf(labelAttr), 1)
        path.node.children = [j.jsxExpressionContainer(labelValue.expression || j.literal(labelValue.value))]
      }
      
      // Rename onClick → onPress
      const onClickAttr = attrs.find(a => a.name?.name === 'onClick')
      if (onClickAttr) onClickAttr.name.name = 'onPress'
    })

  return root.toSource()
}
Running the codemod across all consuming apps:
bash
# In each consuming app
npx jscodeshift \
  --transform node_modules/@company/ui/codemods/button-to-action-button.js \
  --extensions=tsx,ts \
  --parser=tsx \
  src/
Crucial Requirement
Never ship a major version removal without shipping the codemod first. A codemod that runs in under 5 seconds converts what would be a multi-day migration into a one-line command. Teams that dread design system upgrades have almost always experienced a breaking change without a codemod.
Comparison matrix of three design system distribution topologies — Static NPM Packages, Module Federation Runtime Singleton, and Hybrid (NPM tokens + federated chrome widgets) — evaluated across version drift risk, runtime blast radius, rollback speed, bundle overhead, and upgrade coordination cost. Hybrid is highlighted in cyan as the recommended production topology.
Comparison matrix of three design system distribution topologies — Static NPM Packages, Module Federation Runtime Singleton, and Hybrid (NPM tokens + federat…
Mental model diagram of the Hybrid Distribution Architecture showing two separate paths: the Static NPM path (tokens + headless primitives, build-time, zero blast radius) and the Module Federation path (GlobalNav, Footer, CookieBanner, manifest-driven, 30-second rollback). The two paths converge in consuming apps.
Mental model diagram of the Hybrid Distribution Architecture showing two separate paths: the Static NPM path (tokens + headless primitives, build-time, zero…

Summary

ConceptRule
NPM topologyBest for tokens and headless primitives; use Renovate + visual regression for automated upgrades
MF singleton remoteOnly for narrow-interface global chrome components with manifest-driven rollback
Hybrid (recommended)Static NPM for tokens/primitives + runtime MF for GlobalNav, Footer, CookieBanner
Font distributionHost at shared CDN path; load once from App Shell via <link rel="preload">
DeprecationThree phases: Soft → Hard (with codemod) → Removal
Codemod ruleNever ship a major breaking change without a jscodeshift codemod

What's Next

In Part 5, we move from the frontend distribution layer to the backend integration layer — building a Backend-for-Frontend that aggregates downstream microservices, slims payloads to exactly what the client needs, and handles partial failures gracefully so a slow recommendations service never drops a product page.
Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Design System#Module Federation#NPM#Deprecation#Frontend Architecture#Distribution
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.