Skip to main content

Command Palette

Search for a command to run...

Shipping a Component Library That Doesn't Break Production

Published
3 min read

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

  1. Comprehensive Storybook: every component rendered in isolation with all props documented
  2. Changelog: every release listed breaking changes first, deprecations second, new features last
  3. Typescript: caught prop mismatches at build time for consuming teams
  4. 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.

More from this blog