Versioning a Shared Component Library as a Remote #

Shipping a design system as a package lets four remotes render four generations of the same button; shipping it as a federated remote makes them all render one — and makes a bad release everyone’s problem at once.

This page covers how to get the first property without the second: an additive API policy, a compatibility window, a progressive rollout, and a rollback that is a pointer change. It implements the component-library half of design system and theming across remotes.

Two distribution models for one design system Federation buys uniformity and instant rollback at the cost of staggered adoption, which is why the API policy has to be stricter. Uniformity, and what it costs Property As an npm package As a federated remote Versions on one page one per consumer exactly one Shipping a fix every consumer redeploys one deploy Rollback every consumer redeploys one pointer change Blast radius of a bad release one team at a time every remote at once A team holding back easy not possible
The last two rows are the price of the first three — and they are why a federated design system needs progressive rollout.

Prerequisites #

Step 1 — Expose primitives individually, not as one barrel #

A single ./index export means every consumer downloads every component. Exposing each primitive keeps consumers paying only for what they use.

// design-system/webpack.config.js
new ModuleFederationPlugin({
  name: 'ds',
  filename: 'remoteEntry.js',
  exposes: {
    './Button': './src/Button',
    './Input': './src/Input',
    './Card': './src/Card',
    './Modal': './src/Modal',
    // A barrel export defeats the purpose: every consumer pulls everything.
  },
  shared: {
    react: { singleton: true, requiredVersion: '^18.2.0', strictVersion: true },
    'react-dom': { singleton: true, requiredVersion: '^18.2.0', strictVersion: true },
  },
});

strictVersion: true matters here more than anywhere else in the graph. A design system that silently loads a second React means every component it renders uses a different dispatcher from the consuming remote, and the symptoms appear far from the cause.

Step 2 — Make every change additive #

The additive change lifecycle A prop is added beside the old one, deprecated with a warning, measured until nobody uses it, and only then removed in a major. Add, deprecate, measure, remove 3.3 prop added old one still works 3.4 deprecation warning usage measured 3.9 telemetry shows zero use teams migrated 4.0 prop removed dev-only throw explains Removal is gated on telemetry showing zero usage, not on the calendar.
The third marker is the one teams skip: removing on a schedule rather than on evidence is how a major release breaks a page.

Because one version serves every consumer, a breaking change breaks every remote at once with no staggered adoption. The API policy has to be strict enough that this rarely matters.

// Adding a variant: existing usage is untouched.
type ButtonProps = {
  variant?: 'primary' | 'secondary' | 'ghost';   // 'ghost' is new
  size?: 'sm' | 'md' | 'lg';
  /** @deprecated Use `variant="secondary"`. Removed in 4.0. */
  secondary?: boolean;
};

export function Button({ variant, secondary, ...rest }: ButtonProps) {
  // The deprecated prop keeps working, and says so in development.
  if (secondary && process.env.NODE_ENV !== 'production') {
    console.warn('[ds] Button: `secondary` is deprecated; use variant="secondary".');
  }
  const resolved = variant ?? (secondary ? 'secondary' : 'primary');
  return <button data-variant={resolved} {...rest} />;
}

The deprecation warning is the migration tool. It appears in every consumer’s development console, names the replacement, and gives teams a reason to move before the removal — without any coordination meeting.

Removals happen only in a major, and only after telemetry shows nobody is passing the prop any more. That last condition is checkable rather than assumed.

Step 3 — Measure who still uses what #

A deprecation policy without usage data is guesswork. Report it from the component itself.

// design-system/src/telemetry.ts — sampled, aggregated, development-friendly
const reported = new Set<string>();

export function reportDeprecated(component: string, prop: string) {
  const key = `${component}.${prop}`;
  if (reported.has(key)) return;      // once per page view, not per render
  reported.add(key);

  navigator.sendBeacon?.('/rum', JSON.stringify({
    metric: 'ds-deprecated-prop',
    component, prop,
    release: process.env.DS_RELEASE,
    // Which remote rendered it: the whole point of collecting this.
    consumer: document.querySelector('[data-remote]')?.getAttribute('data-remote'),
  }));
}

Deduplicating per page view keeps the volume sane on a page rendering hundreds of buttons. The consumer field is what turns “someone still uses this” into “the cart team still uses this”, which is the difference between a report and a task.

Step 4 — Roll a release out progressively #

A design system release reaches every consumer at once, which is exactly the blast radius progressive rollout exists for.

// shell/src/resolve-ds.js — cohort decides which version resolves
export function resolveDesignSystem(manifest, cohort) {
  const spec = manifest.ds;
  // The canary version is advertised alongside the stable one.
  return cohort === 'canary' && spec.canary
    ? { ...spec, entry: spec.canary }
    : spec;
}

Use the same deterministic cohort assignment described in progressive rollout with feature flags for remotes, so a user stays on one side of the rollout across reloads.

Watch a different signal than usual. A bad design system release rarely throws — it renders. The metrics that catch it are visual regression on the composed preview, layout shift, and error rates inside consuming remotes rather than inside the library.

Step 5 — Keep a compatibility window at the seam #

Why the library must state what it still supports A federated library serves consumers compiled at several versions at once, so compatibility has to be an explicit, testable declaration. No compatibility window Consumers built against 3.2 meet 3.4 A removed prop silently does nothing The bug appears far from its cause Every consumer must upgrade in lockstep Declared window and loud dev errors Library publishes supported consumer majors Removed props throw in development Production ignores them, page still renders Consumers upgrade on their own schedule
Throwing in development and ignoring in production gives the developer an unmissable signal and the user an intact page.

Consumers built against version 3.2 must keep working when the shell resolves 3.4. That holds only if the component’s props are treated as a contract with a stated window.

// design-system/src/compat.ts — the library declares what it still supports
export const COMPAT = {
  // Consumers compiled against these majors will keep working.
  supportedConsumerMajors: [3],
  // Props removed, with the release that removed them, for error messages.
  removed: { 'Button.secondary': '4.0.0', 'Card.dense': '4.0.0' },
};
// A removed prop fails loudly in development rather than silently doing nothing.
if (process.env.NODE_ENV !== 'production' && 'secondary' in rest) {
  throw new Error('[ds] Button: `secondary` was removed in 4.0. Use variant="secondary".');
}

Throwing in development and ignoring in production is the right asymmetry. A developer gets an unmissable error while iterating; a user never sees a page fail because a remote passed a prop that no longer exists.

Publishing the supported-majors list also gives consumers something to assert against in their own CI, which turns “we think we are compatible” into a check.

Step 6 — Make rollback a pointer change #

The strongest argument for shipping the design system as a remote is that reverting it does not require any consumer to deploy.

// scripts/rollback.mjs — re-point the manifest, nothing else
const manifest = await readManifest();
const previous = manifest.ds.history.at(-2);   // the version before the current one

manifest.ds = { ...manifest.ds, ...previous, rolledBackAt: new Date().toISOString() };
await writeManifest(manifest);
console.log(`design system rolled back to ${previous.version}`);

This works only if previous versions remain on the CDN, which is the same immutability requirement as any other remote. A cleanup job that prunes old design system builds quietly removes the ability to revert — and because the design system is the highest-blast-radius remote you have, it is the worst one to lose that ability for.

Keeping a history array in the manifest makes the previous version explicit rather than something a script has to infer from a naming convention.

Step 7 — Decide what belongs in the remote and what stays a package #

Not everything should be federated, and the split is worth making deliberately.

Tokens and primitives — button, input, card, the components whose consistency users actually notice — belong in the remote. Their value comes from being uniform, they change rarely, and a rollback benefits every consumer at once.

Composite components that encode product decisions belong in a package. A checkout summary or a product card carries opinions a team may reasonably want to hold back on, and forcing every consumer onto one version turns a design decision into a release blocker.

{
  "dependencies": {
    "@acme/ds-types": "^3.4.0",       // types for the federated primitives
    "@acme/ds-composites": "^2.1.0"   // versioned per consumer, deliberately
  }
}

Note that even the federated primitives ship a types package. Consumers need the types at build time; they need the implementation at runtime. Keeping those on different distribution channels is what lets one version run everywhere while each team still typechecks against a version it chose.

Verification #

Troubleshooting #

Symptom: two design system versions render on one page.

Diagnosis: a remote bundled the library instead of consuming it — usually because it imports from the package rather than the federated specifier. Fix: import primitives from the remote and reserve the package for types.

Symptom: hooks fail inside design system components.

Diagnosis: the library resolved its own React copy. Fix: singleton: true with strictVersion: true in both the library and every consumer.

Symptom: a release broke one remote and not the others.

Diagnosis: that remote depended on undocumented behaviour — internal class names or a prop that was never public. Fix: publish an explicit API surface and treat everything else as private, then add the missing capability properly.

Symptom: rollback failed because the previous build is gone.

Diagnosis: a retention policy pruned old design system assets. Fix: exclude fingerprinted remote assets from cleanup, as with any other remote.

Symptom: types and runtime disagree after an upgrade.

Diagnosis: the shell resolved a new major while a consumer still has the old types installed. Fix: assert in CI that the types package major matches the manifest’s resolved major.