Piotr Czerwiński

Writing · June 21, 2026 · 10 min read

Extracting a design system into a private package, and falling down the distribution rabbit hole

design systems · npm · CI/CD · Tailwind · monorepo

TL;DR: I extracted the shared UI of my products into a standalone design-system package (React 19 + TypeScript, Tailwind v4 tokens, built with tsup, documented in Storybook) and consumed it from a Next.js app. Four problems along the way taught me more about packaging than years of consuming other people's libraries: a Tailwind major-version clash between producer and consumer, a deleted brand color that kept resurrecting itself through the build output, a dependency that turned out to be type-only, and finally a CI pipeline that could not install the package at all, which forced the real architecture question: how do you distribute a private shared package? The umbrella lesson: a green build locally proves very little about CI, and the gaps between the two are where the learning lives.

Why extract a design system at all

I had UI components copy-pasted across several apps: a job board, a B2B site, a blog. Classic debt: fixing a button in one place does not propagate to the others, and every app slowly drifts toward its own dialect of the brand. The fix is a single source of truth, a package the apps depend on.

The package stack, all latest stable, no betas: React 19 + TypeScript, Tailwind v4 with CSS-first @theme design tokens, tsup for the library build (ESM + CJS + type declarations, code-split per component), Storybook for documentation with an a11y addon and visual regression, Vitest and Playwright for tests. Custom components rather than a skin over an existing kit, because the whole point was full control of the brand.

Wall one: a Tailwind major-version clash

I wire the package into the first consumer app, import its compiled theme CSS, and the build dies:

CssSyntaxError: @layer base is used but no matching
@tailwind base directive is present

Diagnosis: the package was on Tailwind v4, which emits native @layer base/utilities rules. The app was still on v3, whose PostCSS plugin does not understand @layer base without a @tailwind base directive. Not a bug in either one; a major-version mismatch across a package boundary.

Decision: rather than hack backward compatibility into the package, I upgraded the app with the official codemod. The v4 migration in one paragraph: config moves from tailwind.config.js to CSS-first @theme; the three @tailwind directives collapse into a single @import "tailwindcss"; the PostCSS plugin moves to its own package; autoprefixer is no longer needed.

The transferable rule: when a library ships compiled CSS, the Tailwind major must match on both sides of the dependency. If you cannot guarantee that, ship raw tokens and let each consumer compile them itself.

Wall two: the color that would not die

Mid-project I rebranded from indigo to a new accent color. I replaced every occurrence in the source. The built output still contained indigo. Grep the source: clean. Grep the build output directory: one indigo utility class, sitting in the compiled CSS. Where does a class come from if it is not in the code?

Root cause: Tailwind v4 automatically scans the whole project for class names, excluding node_modules and anything gitignored. The static-export output directory was not in .gitignore. So on every build, Tailwind read the previous build's compiled CSS, found the old classes in it, and faithfully re-emitted them into the new CSS. Build N fed build N+1. The old color was self-perpetuating.

The fix was one line in .gitignore (where the directory should have been anyway). The lesson is bigger than the fix: keep build output out of reach of any content scanner, or you get the maddening class of bug where the artifact contradicts the source.

Wall three: the type-only dependency

While auditing the package's manifest I found a form library in dependencies. Checking usage: exactly one import, and it imported only a type. Type-only imports are erased at build time; I confirmed zero bytes of that library in the compiled output. Yet its presence in dependencies force-installed it, with its whole dependency tree, for every consumer of the design system.

The fix: inline a minimal structural type describing the shape the component actually needs (name, change and blur handlers, a ref callback). The form library's own object is structurally assignable to it, so consumers that use the library lose nothing, and consumers that do not are no longer forced to install it.

This connects to the broader question of what actually ships to the browser. The built package was 4 MB on disk (dual module formats, type declarations, source maps, compiled CSS), but a consumer's bundle only receives the tree-shaken chunks of the components it imports. What makes that work: ESM output, an accurate sideEffects field, per-component code splitting, subpath imports instead of one barrel file, and React as a peer dependency so no consumer ever bundles two copies of it.

Wall four: CI cannot install the package

Everything green locally. Push to the integration branch, and:

npm error command git ls-remote ssh://git@github.com/.../design-system.git
npm error git@github.com: Permission denied (publickey)

The package was referenced as a git+ssh dependency pointing at a private repository. My machine has SSH keys for it. The CI runner has none, and the workflow's built-in token is scoped to the current repository only, so it cannot fetch a different private repo. The adoption had simply never run in CI before; this was its first contact with a clean environment, and the clean environment said no.

Two operational lessons came free with the incident:

  • Know your CLI's exit-code semantics before gating on them. I gated a production push on a watch command that returns success for any already-finished run, including failed ones, and pushed on top of a red build. The correct gate queries the run's conclusion explicitly and compares it to success.
  • A failed build is not a broken production. The deploy was CI-gated: the install step failed, so the build and deploy steps never ran, nothing was uploaded, and the live site stayed on the last good version. That is the pipeline doing its job.

The real question: distributing a private package

The CI failure forced the decision I had been deferring. The fundamental truth first: private, plus installable from a registry, always requires a token, on every package manager. "Private, zero credentials, from a registry" is a contradiction. Given that, the options:

  • Monorepo with workspaces (Turborepo or Nx, pnpm workspaces): the package becomes a local workspace dependency. No registry, no tokens, private by definition. CI builds everything together, changes to the design system and its consumers land atomically in one PR, types flow without publishing, and the build cache keeps it fast.
  • A private registry (e.g. GitHub Packages): normal installs, and CI can authenticate with the workflow's built-in token once the repo is granted access. Locally you need a one-time personal token. Reasonable, and the right shape when consumers live in genuinely separate repos.
  • Personal access token or deploy key for git dependencies: works, but now you own a credential with an expiry date, and its failure mode is your install step breaking.
  • Vendoring the built output into consumers: zero tokens, but manual copying, drift, and no changelog. A pragmatic hack, not an architecture.

My choice: the monorepo. It does not solve the distribution problem so much as dissolve it, and every trade-off it introduces (repo size, tooling investment) was one I was happy to pay as a solo maintainer of several apps sharing one brand.

What this teaches

  • "Works locally" proves little. Local builds run against your hand-synced environment. CI does a clean install from the lockfile in an environment with no credentials and no history. That is where the hidden assumptions surface, so get new dependency wiring onto the pipeline early.
  • Distributing a private package is a real engineering decision, not a formality after the "real work" of building the components. It deserves a deliberate choice between monorepo, registry, and credentialed git, made before CI makes it for you.
  • Bundle size is not package size. What matters is what survives tree-shaking, and that is determined by dependency classification, ESM, side-effect declarations, and import shape.
  • The hardest bugs are "the code is fine, the environment is lying": a build output feeding the next build, a CLI exit code that means something other than what you assumed, a runner without the keys your laptop has had for years.