Shipping a Component Library That Doesn't Break Production
Shipping a Component Library That Doesn't Break Production
A component library sounds simple: extract common UI into a package, publish to npm, everyone wins. Spoiler: it's 10x harder than it sounds.
Here's what I learned maintaining one across 12 product teams and 2 million LOC of consuming code.
Problem 1: Semver is a contract
We published v1.2.0 with what we thought was a "harmless" change: renamed color prop to colorScheme for consistency.
Three teams broke in production.
The issue: they were doing prop spreading. Their code looked like:
<Button {...config} label="Click me" />
When config included color: 'blue', the old lib accepted it. New version? Prop gets ignored, UI renders wrong, staging never caught it because staging used different data.
Lesson: Never remove props. Deprecate instead:
export function Button({ color, colorScheme, ...props }) {
const scheme = colorScheme || color;
if (color) console.warn('Button: `color` is deprecated, use `colorScheme`');
// ...
}
Then wait 2 major versions before removing it.
Problem 2: CSS bundle bloat
We had 47 component files, each importing the same utility functions (spacing, typography, colors). Webpack tree-shaking didn't help because utils were re-imported 47 times with different paths.
Result: our CSS bundle was 412 KB (minified). A single <Button> in a new app added 400 KB.
Solution: Single entry point for exports:
// components/index.ts
export * from './Button';
export * from './Card';
// NOT: import Button from './Button'; import Card from './Card';
And in each component file:
// Button.ts (internal only)
export { Button }; // named export, not default
Webpack could now tree-shake properly. Final size: 68 KB.
Problem 3: Peer dependency hell
Our library depended on react@^16, consuming apps used react@17 and react@18.
One team's app shipped with two copies of React (~100 KB duplication).
Solution: Use peer dependencies:
{
"peerDependencies": {
"react": "^16.8 || ^17 || ^18",
"react-dom": "^16.8 || ^17 || ^18"
}
}
Now consumers must install React themselves. No duplication. npm will warn if versions conflict.
Problem 4: Breaking changes in dependencies
We pinned styled-components@^5.2.0. A minor update to 5.3.0 added a new feature that broke SSR for one team (changed how it serializes styles).
Ripple: production outage.
Lesson: Pin all dependency majors in package.json. Allow minors/patches:
{
"dependencies": {
"styled-components": "5.2.1" // pin exact, OR
"date-fns": "^2.28.0" // ^major.minor, allow patch only
}
}
Whenever you bump a dependency, test against all consumer patterns (CSR, SSR, Remix, Next.js, Vite, etc.).
What worked
- Comprehensive Storybook: every component rendered in isolation with all props documented
- Changelog: every release listed breaking changes first, deprecations second, new features last
- Typescript: caught prop mismatches at build time for consuming teams
- Integration tests against real consuming apps (not just component snapshots)
The hidden cost
Maintaining a library for 12 teams is more like on-call support than feature development. Most of my time was:
- Debugging why Consumer Team X's build broke after an update they didn't even request
- Reviewing PRs from teams who want to add "just one more component"
- Handling semver violations (they always happen)
If you're starting a shared library, budget 1 FTE minimum for maintenance. It's not optional if you want teams to trust it.
The takeaway: a component library is infrastructure, not code. Treat it with the rigor you'd use for a database driver or auth service. Semver discipline, peer dependencies, minimal surface area, and honest changelogs save everyone pain downstream.
See the full library and docs at my portfolio.
