Skip to content

Versioning and Migration Strategy

advanced18 min read

Versioning Is a Promise

When you publish a component library, the version number is a promise to your consumers. "If you upgrade from 2.3.0 to 2.4.0, nothing will break." That is the social contract of semantic versioning, and violating it destroys trust faster than almost anything else.

The tricky part for design systems: what counts as a "breaking change"? A renamed prop is obviously breaking. But what about changing the default border-radius from 6px to 8px? What about a component that was 40px tall and is now 44px? These are visual changes, not API changes, but they can absolutely break layouts.

Mental Model

Semver for a component library is like a restaurant's menu. A major version change is like changing the entire menu — customers need to decide if they still want to eat here. A minor version is adding new dishes — existing favorites are untouched. A patch is fixing a recipe — the dish you ordered tastes as expected, just without the bug that made it too salty. When you change an existing dish without warning, customers get surprised and angry — even if you think the new version is better.

What Constitutes a Breaking Change

Here is the definitive list for component libraries. If you do any of these, it is a major version bump:

Definitely Breaking (Major)

Not Breaking (Minor or Patch)

  • Adding a new optional prop
  • Adding a new component or export
  • Adding a new variant to an existing component
  • Fixing a bug (component was not doing what docs said)
  • Improving accessibility without changing visual appearance
  • Internal refactoring with no external behavior change
Quiz
Your Button component currently has padding: 8px 16px. You change it to padding: 10px 20px to better match your updated design spec. What version bump is this?

Changelog Generation with Changesets

Manually maintaining a changelog is error-prone. Changesets automates it by requiring developers to declare the impact of their changes at PR time.

npm install @changesets/cli
npx changeset init

When a developer makes a change, they run:

npx changeset

This prompts them to:

  1. Select which packages changed
  2. Classify the change as major, minor, or patch
  3. Write a human-readable description

The result is a markdown file in .changeset/:

---
"@acme/ui": minor
---

Add `loading` prop to Button component. When `loading` is true, the button shows a spinner and is disabled.

When you are ready to release, run:

npx changeset version

This:

  • Bumps version numbers in package.json
  • Consolidates all changeset files into CHANGELOG.md
  • Deletes the consumed changeset files
## 2.4.0

### Minor Changes

- Add `loading` prop to Button component. When `loading` is true, the button shows a spinner and is disabled.

Then publish:

npx changeset publish
Key Rules
  1. 1Every PR that changes component behavior or API must include a changeset
  2. 2Changesets are written by the developer who made the change — they know the intent best
  3. 3The changeset message should explain what changed from the consumer's perspective, not the implementation details
  4. 4Block PRs without changesets in CI (unless the change is internal-only like docs or tests)

CI Enforcement

# .github/workflows/changeset-check.yml
name: Changeset Check
on: pull_request

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - run: npm ci
      - run: npx changeset status --since=origin/main

The changeset status command fails if the PR includes publishable changes but no changeset file.

Quiz
A developer refactors Button's internal state management from useState to useReducer. No props changed, no visual changes, no behavior changes. Should they add a changeset?

Migration Codemods with jscodeshift

When you do ship a breaking change (major version), help your consumers migrate. Codemods are automated code transformations that update consumer code to match the new API.

jscodeshift is the standard tool. It parses code into an AST, lets you find and replace patterns, and writes the transformed code back:

// codemod: rename variant="primary" to variant="default"
import type { API, FileInfo } from 'jscodeshift';

export default function transformer(file: FileInfo, api: API) {
  const j = api.jscodeshift;
  const root = j(file.source);

  root
    .findJSXElements('Button')
    .find(j.JSXAttribute, { name: { name: 'variant' } })
    .filter(path => {
      const value = path.node.value;
      return (
        value?.type === 'StringLiteral' && value.value === 'primary'
      );
    })
    .forEach(path => {
      if (path.node.value?.type === 'StringLiteral') {
        path.node.value.value = 'default';
      }
    });

  return root.toSource();
}

Consumers run:

npx jscodeshift -t ./codemods/rename-button-variant.ts ./src/**/*.tsx

Before:

<Button variant="primary">Submit</Button>

After:

<Button variant="default">Submit</Button>

Codemod for Prop Removal

// codemod: remove deprecated "outline" prop, add variant="outline" instead
export default function transformer(file: FileInfo, api: API) {
  const j = api.jscodeshift;
  const root = j(file.source);

  root
    .findJSXElements('Button')
    .forEach(path => {
      const attrs = path.node.openingElement.attributes;
      if (!attrs) return;

      const outlineIndex = attrs.findIndex(
        attr =>
          attr.type === 'JSXAttribute' &&
          attr.name.name === 'outline'
      );

      if (outlineIndex === -1) return;

      attrs.splice(outlineIndex, 1);

      const hasVariant = attrs.some(
        attr =>
          attr.type === 'JSXAttribute' &&
          attr.name.name === 'variant'
      );

      if (!hasVariant) {
        attrs.push(
          j.jsxAttribute(
            j.jsxIdentifier('variant'),
            j.stringLiteral('outline')
          )
        );
      }
    });

  return root.toSource();
}

Before:

<Button outline>Cancel</Button>

After:

<Button variant="outline">Cancel</Button>
Common Trap

Codemods cannot handle every edge case. Dynamic props (variant={isImportant ? 'primary' : 'default'}), spread props ({...buttonProps}), and higher-order components (const MyButton = styled(Button)) are difficult or impossible to transform automatically. Always document what the codemod handles and what requires manual migration.

The Deprecation Lifecycle

Never remove something without warning. The deprecation lifecycle gives consumers time to migrate:

Execution Trace
v2.5.0 — Deprecate
Mark the old API as deprecated with console.warn in dev mode
Add @deprecated JSDoc tag so editors show warnings
v2.5.0 — Document
Update docs to show the new API with migration instructions
Link to codemod if available
v2.6.0 — Remind
Keep the deprecation warning, add it to the changelog
Monitor usage analytics to track migration progress
v3.0.0 — Remove
Remove the deprecated API in the next major version
Ship the codemod with the major release

Implementing Deprecation Warnings

interface ButtonProps {
  variant?: 'default' | 'destructive' | 'outline' | 'ghost';
  /** @deprecated Use `variant="outline"` instead. Will be removed in v3.0. */
  outline?: boolean;
}

function Button({ variant, outline, ...props }: ButtonProps) {
  if (process.env.NODE_ENV !== 'production' && outline !== undefined) {
    console.warn(
      '[ACME UI] Button: The "outline" prop is deprecated. ' +
      'Use variant="outline" instead. ' +
      'This prop will be removed in v3.0. ' +
      'Run `npx @acme/ui-codemods button-outline` to migrate automatically.'
    );
  }

  const resolvedVariant = outline ? 'outline' : (variant ?? 'default');

  return <button className={buttonVariants({ variant: resolvedVariant })} {...props} />;
}

The warning:

  • Only fires in development (stripped from production builds)
  • Says what is deprecated
  • Says what to use instead
  • Says when it will be removed
  • Links to the automated migration tool
Quiz
You want to rename the Button's size='small' to size='sm' to match your new convention. What is the correct approach?

Gradual Adoption Strategy

Large organizations cannot upgrade all consumers simultaneously. Your versioning strategy must support gradual adoption.

Strategy 1: Side-by-Side Versions

Allow consumers to install two major versions simultaneously:

{
  "dependencies": {
    "@acme/ui": "^2.0.0",
    "@acme/ui-v3": "npm:@acme/ui@^3.0.0"
  }
}
import { Button } from '@acme/ui';           // v2
import { Button as ButtonV3 } from '@acme/ui-v3'; // v3

This works but doubles the bundle. Use it for gradual migration, not permanent coexistence.

Strategy 2: Feature Flags

Ship the new behavior behind a flag in a minor release:

import { DesignSystemProvider } from '@acme/ui';

<DesignSystemProvider features={{ newButtonSizing: true }}>
  <App />
</DesignSystemProvider>

Consumers opt in at their own pace. When the flag is stable, make it the default in the next major.

What developers doWhat they should do
Shipping breaking changes in minor or patch versions
A 'small' visual change can break 50 consumer layouts. Trust is built by respecting semver religiously
Always bump major for breaking changes, no matter how small they seem
Writing changelogs from the implementation perspective (Refactored Button state to useReducer)
Consumers do not care about your internal refactors. They care about what changed for them
Write changelogs from the consumer perspective (Button now supports keyboard shortcut via shortcutKey prop)
Removing deprecated APIs without a major version bump
Some consumers update infrequently. A deprecated API removed in a minor version breaks their build unexpectedly
Removals always require a major version, even if the API was deprecated for years
Shipping codemods that modify files in place without backup
Codemods are not perfect. Consumers must review every change. Encourage running on a clean git state so the entire transformation is reviewable as a single diff
Codemods should work with git — consumers run them on a clean working tree and review the diff