← All Blogs

Reusable UI npm Package: The Design Decisions That Mattered

Reusable UI npm package design decisions

by Abdelkader Settah

May 11, 2026

Reusable UI npm Package: The Design Decisions That Mattered

A while back I built and shipped a private UI npm package for our team. Setup time on new projects dropped by about 40 percent. That’s the number I quote, but the value isn’t in the percentage. It’s in not having three slightly different <Button> components rotting across three repos.

These are the decisions that actually mattered, and a couple I’d revisit.

Decision 1: Peer dependencies, not bundled ones

The package depends on React, React DOM, and Tailwind. None of them are bundled. They live in peerDependencies:

{
  "peerDependencies": {
    "react": ">=18.0.0",
    "react-dom": ">=18.0.0",
    "tailwindcss": ">=3.0.0"
  }
}

If you bundle React, you ship two copies in any consumer that already has its own. Two copies of React breaks hooks and context in surprising ways. Peer deps are the only correct choice here.

A small lesson learned: list peerDependenciesMeta so optional ones (like icon libraries) don’t trigger install warnings.

Decision 2: Components ship as source, not pre-compiled CSS

The first version of the package shipped pre-compiled CSS. It worked, but Tailwind couldn’t tree-shake unused styles in the consumer app. Bundle bloat.

The second version ships components as .tsx source. The consumer’s Tailwind config picks up classes via the content glob:

// tailwind.config.js (in the consumer app)
module.exports = {
  content: [
    './src/**/*.{ts,tsx}',
    './node_modules/@org/ui/**/*.{ts,tsx}',
  ],
};

Smaller bundles, and the consumer can override styling without ejecting from the package.

Decision 3: One package, multiple entry points

Early on, every component was importable from the package root:

import { Button, Modal } from '@org/ui';

Tree-shaking helps, but it’s not free. Now each component has its own entry:

import { Button } from '@org/ui/button';
import { Modal } from '@org/ui/modal';

This is set up in package.json via exports:

{
  "exports": {
    ".": "./dist/index.js",
    "./button": "./dist/button.js",
    "./modal": "./dist/modal.js"
  }
}

The barrel is still there for prototyping. Production code uses the per-component entries.

Decision 4: Versioning with Changesets, releasing on merge

We use Changesets. Every PR that touches the package includes a .changeset markdown file describing the change and severity (patch, minor, major). On merge to main, a release PR opens automatically. Approving it bumps the version and publishes.

Without this, releases happen rarely or sloppily. With it, releases happen on every meaningful change and the consumer sees a clean changelog.

What I’d revisit

A couple of things I’m not sure I got right:

  1. Theming. I went with CSS custom properties. It works, but every theme-aware component has to read the variable, which adds friction. A Tailwind plugin approach might fit better next time.
  2. Storybook. We have it, but it’s not the source of truth for behavior tests. A real test harness (Playwright component tests, for example) would have caught regressions earlier.

Conclusion

The package paid off, but the parts that mattered weren’t the components themselves. They were the boring infrastructure: peer deps, source distribution, per-component entries, automated releases. Get those right and the components mostly take care of themselves.

Summary

Use peerDependencies for shared runtime libraries. Ship source so the consumer’s bundler can tree-shake. Provide per-component entries via exports to avoid pulling in the whole package on every import. Automate releases with Changesets. Theming via CSS variables works but is not friction-free. Storybook is not a substitute for behavioral testing.


Packaging a design system is most of the work; the components are the easy part. If your team is about to start (or rebuild) one, I take on React consulting engagements, including design-system architecture and packaging.