Keeping a UI Kit Updated Across Apps: Versioning, Contracts, and Trust

The hardest part of a UI kit is not writing the components. That's the trap. You can build a clean <Button>, a flexible <Select>, a beautiful <Modal>, publish them to a package, and feel like the system exists. Then six months later one app is on version 1.4, another is on 2.1, a third has copied half the styles locally, and everyone is scared to upgrade because nobody knows what will break.
At that point the UI kit did not fail as a component library. It failed as a product.
The previous article was about changing a shared component inside one codebase, where you can grep every call site and run a codemod yourself. This is the harder version: several applications consume the kit as a versioned package. You can't see every usage. You can't update every app in one pull request. You can't assume every team upgrades on the same day. So the question changes from "how do I make the component better?" to "how do I make upgrades safe enough that people keep taking them?"
That's the real work.
A UI kit is not shared until upgrades are routine
A lot of teams treat "published package" as the finish line. It isn't. A package can be shared and still be effectively frozen. If every upgrade requires a migration meeting, manual QA across five apps, and someone brave enough to merge first, the kit is not healthy. It's a museum.
The signal I care about is boring: can an app upgrade from 2.3.1 to 2.3.2 on a normal Tuesday without drama? Can it take a minor version without reading every diff? Can it delay a major version without blocking everyone else? If the answer is yes, the UI kit is alive. If the answer is no, every improvement you make piles up behind the same wall: adoption risk.
A design system is only as real as its upgrade path. If people cannot safely move to the next version, the new version mostly exists in your repository.
This is why "one version everywhere" is a nice goal and a terrible starting assumption. You may want every app on the latest version. You probably should. But the mechanism that gets you there is not hope. It's compatibility, contracts, release discipline, and migration support.
Semver is a promise, not a decoration
Version numbers are not vibes. They are a communication system between the kit and every app that depends on it. If you treat them casually, people stop trusting them.
The contract is simple:
{ "name": "@company/ui-kit", "version": "2.4.0" }
A patch release fixes bugs and should be safe. A minor release adds capabilities without breaking existing usage. A major release may require changes, and the migration path must be explicit. That's semver on paper. In practice, the hard part is being honest about what counts as breaking.
Changing a prop type is obviously breaking. Removing a component is breaking. But so is changing keyboard behavior in a menu. So is changing focus trapping in a modal. So is changing the DOM structure if consumers style nested elements, even if you wish they didn't. So is renaming a CSS variable that apps rely on. UI libraries have more contracts than TypeScript can see.
That means the rule has to be conservative: if a reasonable consumer could notice the change in code, behavior, accessibility, or visual output, treat it as a contract change. Maybe it's still worth doing. Just don't smuggle it into a patch release and call the upgrade safe.
Public API must be smaller than the folder tree
This connects directly back to the barrel files article. A UI kit needs a public API, but that API has to be intentional. If apps can import from anywhere inside the package, every internal file becomes a promise you didn't mean to make.
Bad shape:
import { Button } from '@company/ui-kit/src/components/button/button'; import { buttonRecipe } from '@company/ui-kit/src/components/button/styles'; import { useModalMachine } from '@company/ui-kit/src/internal/modal-machine';
Now your internal structure is part of the external contract. You can't reorganize files. You can't swap the modal implementation. You can't rename a style recipe. The consuming apps have turned your source tree into their API.
Good shape:
import { Button, Dialog, Select } from '@company/ui-kit'; import { useCombobox } from '@company/ui-kit/headless'; import { tokens } from '@company/ui-kit/tokens';
Three doors. Components, headless behavior, tokens. Everything else is private. If someone needs a new door, you add it intentionally. You don't let them crawl through a window because the import path happened to work.
In package terms, that means using exports to make the boundary real:
{ "name": "@company/ui-kit", "exports": { ".": "./dist/index.js", "./headless": "./dist/headless/index.js", "./tokens": "./dist/tokens/index.js" } }
This is a small detail with a large consequence. You can only version what you control. A tight public API gives you something finite to protect.
Contract tests are how apps talk back
The uncomfortable part of maintaining a UI kit across apps is that unit tests inside the kit are not enough. They tell you your component works according to your assumptions. They do not tell you whether the apps use it in ways you forgot to protect.
The answer is not to pull every application into your test suite. That turns the kit into a build-system hostage. The better answer is a thin layer of contract tests: small fixtures that represent the promises the kit makes to consumers.
// contracts/dialog.contract.tsx import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Dialog } from '@company/ui-kit'; it('keeps focus inside the dialog until it closes', async () => { render( <Dialog open title="Delete customer"> <button>Cancel</button> <button>Delete</button> </Dialog>, ); await userEvent.tab(); expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus(); await userEvent.tab(); expect(screen.getByRole('button', { name: 'Delete' })).toHaveFocus(); });
That test is not checking implementation. It is checking a consumer-visible promise: focus stays inside the dialog. If you rewrite the internals from one focus-trap library to another, the contract should still pass. If it doesn't, you didn't make a refactor. You changed behavior.
You need contracts in four layers:
Type contracts. A small TypeScript test project that imports the public API the way apps do. If exported types break, the kit fails before publishing.
Behavior contracts. Testing Library tests for keyboard behavior, focus, ARIA, controlled/uncontrolled state, and callback order.
Visual contracts. Storybook or fixture pages with visual regression on the stable states: default, hover, disabled, loading, error, long text, right-to-left if you support it.
Token contracts. Tests that verify CSS variables and design tokens still exist with expected names. Tokens are API. Treat them that way.
Not every component needs all four layers. A plain Badge probably needs visual and token coverage. A Combobox needs behavior and accessibility coverage badly. The point is to test the promises consumers depend on, not every internal branch.
Release channels keep risk from spreading
If every release goes straight to latest, your first consumer becomes the canary whether they agreed to it or not. That's not fair to them, and it trains people to wait. A better release flow gives the kit a place to be wrong before the stable version moves.
For example:
# experimental builds for early adopters npm publish --tag next # stable builds after contracts and canary apps pass npm publish --tag latest
The next tag is where breaking work, risky rewrites, and big visual changes can be tested by one or two willing apps. latest is the calm channel. It should feel boring. If a team installs latest and gets surprise churn, they will stop trusting the kit. And when trust goes, adoption goes with it.
This does not require a huge platform. Start with one canary app — ideally the app that uses the kit heavily and has the fastest feedback loop. Upgrade it first. Run its smoke tests. Look at the core screens. Fix the kit before asking everyone else to move. The canary app is not there to prove the kit is perfect. It is there to catch the obvious problems while the blast radius is still small.
Migration support is part of the feature
A breaking release without a migration path is not a release. It's homework assigned to every consuming app.
If you remove Button color in favor of Button intent, the release should include more than a changelog sentence. It should include the same tools from the previous article, but packaged for consumers who live outside your repo:
{ "scripts": { "migrate:ui-kit": "npx @company/ui-kit-codemods button-color-to-intent src" } }
The codemod can ship as a small companion package. The migration guide should show old usage, new usage, edge cases, and what the script can and cannot fix. The deprecation should exist for at least one minor cycle before the major removal, unless the old behavior is actively dangerous. And the major release should be boring precisely because the hard work already happened while the old API still existed.
Honestly, this is where many UI kits lose people. The maintainers ship a clean new API and assume consumers will be grateful. Consumers look at their backlog, see forty files to touch, and pin the old version for another year. The new API may be better, but the upgrade experience is worse, so nobody moves.
Documentation has to be versioned too
One small but painful failure mode: the documentation shows the latest version, but half the apps are still on an older one. A developer opens the docs, copies an example, and the prop doesn't exist. Now the UI kit feels broken even though the app is just behind.
If multiple apps can be on different versions, the docs need to acknowledge that reality. At minimum, every docs page should clearly say which version it describes. Better: keep versioned docs for major versions, or at least preserve migration pages long after the release goes out. A migration guide is most useful six months after you wrote it, when the last app finally upgrades.
This also applies to design tokens and screenshots. If your docs render from the latest package but an app is on v2, the visual examples can lie. Sometimes that's acceptable. Sometimes it creates bugs. Be explicit either way.
The rule I use: optimize for trust
There are many tactical choices here — monorepo or separate repo, Changesets or semantic-release, Storybook or Ladle, visual regression provider A or B. Those choices matter, but they're not the center. Trust is the center.
Trust means a patch release doesn't redesign the app. Trust means a minor release doesn't remove a prop. Trust means a major release comes with a migration path. Trust means public imports stay public and private imports are actually blocked. Trust means if a component's behavior changes, there is a test that calls it a behavior change. Trust means teams believe the kit maintainers are trying to make upgrades smaller, not throw work over the wall.
Once that trust exists, you can improve the UI kit continuously. Without it, every improvement becomes suspicious. People fork. They pin. They copy styles locally. They build one-off replacements because waiting for the shared kit feels slower than escaping it. That is how a design system dies: not from one bad component, but from a long series of upgrades people chose not to take.
This closes the component-patterns run: compound components for structure, headless components for behavior, incremental migration inside one repo, and now versioned upgrades across many apps. Next I want to move into state and data at scale, starting with the deceptively simple persistence question: what belongs in local storage, what belongs in global state, and what breaks when you pretend they're the same thing.
If your UI kit is stuck on an old version somewhere, I'm curious what the real blocker is. Is it semver trust, missing migration tools, visual regression fear, or just nobody owning the upgrade? Send me the ugly reason. That's usually where the architecture lives.
Found this helpful?
Subscribe on Telegram for new posts, or reach out to discuss your project.