Versioning and Publishing Component Libraries
A practical guide to versioning, releasing, and distributing TypeScript component libraries using semantic versioning, changesets, and automated CI/CD publishing pipelines.
Building a great component library is only half the challenge. Getting it into the hands of consuming teams reliably, predictably, and without breaking their applications is the other half. Versioning and publishing are the mechanisms that bridge your source code and your consumers' node_modules. Get them wrong, and you ship breaking changes in patch releases, force manual coordination for every update, or worse — make teams afraid to upgrade at all. This article covers a complete versioning and publishing pipeline for TypeScript component libraries, from semantic versioning decisions to fully automated releases.
Semantic Versioning for Component Libraries
Semantic versioning (semver) is the contract between your library and its consumers. The rules are well-known: major versions introduce breaking changes, minor versions add backward-compatible features, and patch versions fix bugs. But applying these rules to a component library requires judgment on what constitutes a "breaking change" in the context of UI components.
Here is a classification that has served us well:
Major (breaking):
- Removing a component or exported function
- Removing or renaming a prop
- Changing a prop's type in a way that existing usage no longer compiles
- Changing default behavior (e.g., a Modal that used to close on overlay click no longer does)
- Dropping support for a React version
- Changing the required CSS import path
Minor (feature):
- Adding a new component
- Adding a new optional prop to an existing component
- Adding a new variant or size option
- Adding a new exported hook or utility
- Expanding a union type (adding a new value to
variant)
Patch (fix):
- Fixing a visual bug without changing the API
- Fixing a keyboard navigation issue
- Fixing a TypeScript type that was too narrow
- Performance improvements with no API changes
- Updating documentation
// Example: This change is a MINOR bump, not a patch
// Before (v2.3.0):
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'ghost';
}
// After (v2.4.0 — new variant added):
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
}
// Example: This change is a MAJOR bump
// Before (v2.4.0):
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
}
// After (v3.0.0 — prop renamed):
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
dimension?: 'sm' | 'md' | 'lg'; // renamed from 'size'
}Edge cases to watch for:
- Widening a return type is technically breaking. If a hook returned
stringand now returnsstring | null, consumers with strict null checks will see compiler errors. This is a major change. - Narrowing a parameter type is breaking. If a prop accepted
stringand now only accepts specific union members, existing usage may break. Major change. - Adding a required prop is breaking. Existing JSX that does not provide the new prop will fail to compile. Major change.
- CSS class name changes are breaking if consumers target them for styling or testing. Document your class naming as part of your public API — or explicitly declare it as internal.
Managing Versions with Changesets
Manually tracking what changed in each release is error-prone and tedious. Changesets automate this process by linking version bumps to the pull requests that caused them.
The workflow is straightforward: when a developer makes a change that affects consumers, they add a changeset file that describes what changed and what semver bump it warrants. At release time, the tool aggregates all changesets, determines the correct version bump, generates a changelog, and publishes.
Install and configure changesets:
// .changeset/config.json
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": [
"@changesets/changelog-github",
{ "repo": "klivvr/messier" }
],
"commit": false,
"fixed": [],
"linked": [
["@messier/core", "@messier/tokens", "@messier/themes"]
],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@messier/docs", "@messier/playground"]
}The linked configuration is important for monorepos. It ensures that packages that should always be on the same version are bumped together. When @messier/tokens gets a minor bump, @messier/core (which depends on it) also gets a minor bump.
Adding a changeset is a CLI command that produces a markdown file:
npx changesetThis interactive prompt asks which packages changed and what type of bump (major/minor/patch). The result is a file like:
---
"@messier/core": minor
---
Added `danger` variant to Button component. Use `<Button variant="danger">` for destructive actions like delete or remove operations.Require changesets in CI. A GitHub Action can check that every PR touching package source code includes a changeset:
# .github/workflows/changeset-check.yml
name: Changeset Check
on:
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Check for changeset
run: |
npx changeset status --since origin/mainThe Release Pipeline
With changesets accumulating on main, you need a release process that versions packages, generates changelogs, and publishes to your registry. This should be fully automated.
Here is a complete GitHub Actions workflow for automated releases:
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- name: Build packages
run: npm run build
- name: Run tests
run: npm test
- name: Create release PR or publish
uses: changesets/action@v1
with:
publish: npx changeset publish
version: npx changeset version
title: 'chore: version packages'
commit: 'chore: version packages'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}The changesets GitHub Action has two modes. When there are pending changesets, it creates (or updates) a "Version Packages" pull request that applies all version bumps and updates changelogs. When that PR is merged, the action detects that there are no pending changesets and runs changeset publish, which publishes the new versions to npm.
This two-step process gives you a final review before publishing. The version PR shows exactly what will be released — which packages, which versions, and the complete changelog.
Pre-release and Canary Channels
Not every version should go to stable. New features need testing in real applications before a stable release. Pre-release channels let consuming teams opt into upcoming changes.
Pre-releases for upcoming majors. When preparing a major version, publish release candidates:
// In your changeset config, enter pre-release mode:
// npx changeset pre enter rc
// Now every "changeset version" produces versions like:
// @messier/core@3.0.0-rc.0
// @messier/core@3.0.0-rc.1
// ...
// Exit pre-release mode when ready for stable:
// npx changeset pre exitCanary releases from PRs. For immediate feedback, publish canary versions from pull request branches. This lets a consuming team test a fix before it is merged:
# .github/workflows/canary.yml
name: Canary Release
on:
issue_comment:
types: [created]
jobs:
canary:
if: github.event.issue.pull_request && contains(github.event.comment.body, '/canary')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: refs/pull/${{ github.event.issue.number }}/head
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm run build
- name: Publish canary
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
PR_NUM=${{ github.event.issue.number }}
for pkg in packages/*/; do
if [ -f "$pkg/package.json" ]; then
cd "$pkg"
npm version prerelease --preid="pr${PR_NUM}.${SHORT_SHA}" --no-git-tag-version
npm publish --tag canary
cd -
fi
done
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Comment with install command
uses: actions/github-script@v7
with:
script: |
const sha = context.payload.pull_request?.head.sha.substring(0, 7) || 'unknown';
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Canary published! Install with:\n\`\`\`\nnpm install @messier/core@canary\n\`\`\``
});A developer comments /canary on a PR, and a few minutes later they can install and test the exact changes in that PR within their application.
Package Entry Points and Subpath Exports
Modern component libraries should support both barrel imports and subpath imports. Barrel imports are convenient; subpath imports enable better tree-shaking and smaller bundles.
{
"name": "@messier/core",
"version": "2.4.0",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./button": {
"import": {
"types": "./dist/components/Button/index.d.ts",
"default": "./dist/components/Button/index.mjs"
},
"require": {
"types": "./dist/components/Button/index.d.cts",
"default": "./dist/components/Button/index.cjs"
}
},
"./styles": "./dist/styles.css",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"button": ["./dist/components/Button/index.d.ts"]
}
}
}The typesVersions field ensures TypeScript can resolve types for subpath imports in older TypeScript versions that do not fully support the exports field.
Generate these entry points from your component directory structure:
// scripts/generate-exports.ts
import fs from 'fs';
import path from 'path';
const componentsDir = path.resolve(__dirname, '../packages/core/src/components');
const pkgPath = path.resolve(__dirname, '../packages/core/package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
const exports: Record<string, any> = {
'.': {
import: { types: './dist/index.d.ts', default: './dist/index.mjs' },
require: { types: './dist/index.d.cts', default: './dist/index.cjs' },
},
'./styles': './dist/styles.css',
'./package.json': './package.json',
};
const typesVersions: Record<string, string[]> = {};
const components = fs.readdirSync(componentsDir, { withFileTypes: true })
.filter((d) => d.isDirectory());
for (const component of components) {
const subpath = `./${component.name.toLowerCase()}`;
const distPath = `./dist/components/${component.name}/index`;
exports[subpath] = {
import: { types: `${distPath}.d.ts`, default: `${distPath}.mjs` },
require: { types: `${distPath}.d.cts`, default: `${distPath}.cjs` },
};
typesVersions[component.name.toLowerCase()] = [`${distPath}.d.ts`];
}
pkg.exports = exports;
pkg.typesVersions = { '*': typesVersions };
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
console.log(`Generated exports for ${components.length} components.`);Deprecation and Migration Strategy
Eventually you will need to deprecate components, rename props, or introduce breaking changes. A responsible deprecation strategy gives consumers time and tools to migrate.
Phase 1: Deprecation warning. Mark the old API as deprecated. TypeScript's JSDoc @deprecated tag shows strikethrough in editors. Add a runtime warning in development:
function useDeprecationWarning(componentName: string, oldProp: string, newProp: string, value: unknown) {
if (process.env.NODE_ENV === 'development' && value !== undefined) {
console.warn(
`[@messier/core] ${componentName}: The "${oldProp}" prop is deprecated and will be removed in the next major version. Use "${newProp}" instead.`
);
}
}
// Usage inside a component
interface AlertProps {
/** @deprecated Use `variant` instead */
type?: 'info' | 'success' | 'warning' | 'error';
variant?: 'info' | 'success' | 'warning' | 'error';
children: React.ReactNode;
}
function Alert({ type, variant, children }: AlertProps) {
useDeprecationWarning('Alert', 'type', 'variant', type);
const resolvedVariant = variant ?? type ?? 'info';
return (
<div className={`alert alert-${resolvedVariant}`} role="alert">
{children}
</div>
);
}Phase 2: Codemod. Ship a codemod that automates the migration:
// codemods/alert-type-to-variant.ts
import type { Transform } from 'jscodeshift';
const transform: Transform = (fileInfo, api) => {
const j = api.jscodeshift;
const root = j(fileInfo.source);
root
.find(j.JSXOpeningElement, { name: { name: 'Alert' } })
.find(j.JSXAttribute, { name: { name: 'type' } })
.forEach((path) => {
path.node.name.name = 'variant';
});
return root.toSource();
};
export default transform;Phase 3: Removal. In the next major version, remove the deprecated API. The changelog and migration guide should reference the codemod.
The timeline between phases should be generous. We recommend at least one full minor version cycle (typically 1-3 months) between deprecation and removal. This gives teams time to plan and execute the migration.
Conclusion
Versioning and publishing are the supply chain of your design system. Semantic versioning communicates intent. Changesets automate version management. A CI/CD pipeline eliminates human error from the release process. Pre-release channels enable safe experimentation. And a thoughtful deprecation strategy respects your consumers' time and builds the trust that keeps them upgrading. Get this infrastructure right, and your design system can evolve continuously while remaining a stable foundation for every product that depends on it.
Related Articles
Measuring the ROI of a Design System
A practical framework for measuring the return on investment of a design system, covering efficiency metrics, quality improvements, consistency gains, and how to communicate value to stakeholders.
Documentation That Developers Actually Read
How to create design system documentation that developers engage with, covering information architecture, interactive examples, API references, and documentation-as-code workflows.
Testing Strategies for UI Component Libraries
A comprehensive testing strategy for design system component libraries, covering unit tests, interaction tests, visual regression tests, and accessibility audits in TypeScript.