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.
Prerequisites #
- A federated setup where the host resolves remotes from a manifest, per semantic versioning for Module Federation remotes.
- React 18+ shared as a singleton across the library and every consumer.
- Published TypeScript declarations for the exposed components.
- A progressive rollout mechanism, since a design system release reaches every remote simultaneously.
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 #
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 #
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 #
- One version on the page: in the console, confirm every rendered primitive reports the same
data-ds-releasevalue. - Consumers do not bundle it: check a remote’s build output for design system code. Finding it means the shared configuration is wrong.
- One React instance: the share scope should list exactly one version of
react. - Deprecations are visible: render a deprecated prop in development and confirm the console warning names the replacement.
- Rollback works: roll back in staging and confirm every remote renders the previous version with no consumer deploy.
- Types match runtime: confirm the installed types package major matches the resolved remote’s major.
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.