$ cd ../blog

From Internal Component to npm Package: Shipping Open Source That People Actually Use

·5 min read

The gap between "component that works in our app" and "package strangers can use" is much wider than it looks. I learned this publishing scheduler-calendar, a React scheduling component that started life inside a SaaS product and ended up on npm with real external users.

Here's what the journey actually involved — and what I'd do differently.

Your internal API is full of assumptions you can't see

Inside our product, the calendar component received data in exactly the shape our backend produced, styled itself with our design tokens, and assumed our timezone handling. None of that was visible as coupling — it was just "how the component works."

Extracting it for npm surfaced every hidden assumption:

  • Data shape. External users have their own availability formats. The public API had to accept a minimal, documented structure and nothing more.
  • Styling. Hardcoded colors became CSS module classes with override points. If users can't restyle your component, they'll fork it or leave.
  • Time. Timezones, week-start conventions, 12/24-hour formats — everything we'd baked in as "obviously Monday, obviously 24h" became a prop with a default.

The exercise made the internal version better too. Ruthlessly separating "core behavior" from "our app's preferences" is just good component design that nobody forces you to do until strangers depend on it.

TypeScript types are the product

Here's the thing that surprised me most: for a developer-facing package, the TypeScript experience is the user experience. Most users never read the README a second time. They live in autocomplete.

That means:

  • Export every public type. Users will build wrappers, and they need your types to do it without any.
  • Make invalid states unrepresentable. A union type that forbids endTime before startTime at the type level prevents a whole category of support issues.
  • Write JSDoc on props. Hover documentation in the editor gets read a hundred times more than your docs site.

A package with mediocre features and excellent types beats the reverse. Types are documentation that can't go stale.

The documentation bar is higher than you think

My rule after doing this once: a developer should get a working example rendered in under five minutes, copy-pasting from the README alone. That requires:

  • A minimal example that actually runs — not pseudocode, not a snippet missing its imports.
  • A props table with types and defaults for everything public.
  • A live demo. Nothing sells a UI component like seeing it move.

Every support question you receive is a documentation bug. I started treating GitHub issues that asked "how do I..." as PRs waiting to happen against the README.

Maintenance is the real cost, so design for less of it

The dirty secret of open source is that publishing is the cheap part. Every feature you add is a feature you maintain against every React version bump for the life of the package. Two decisions kept the burden manageable:

  • Small API surface. I said no to many feature requests that were wrappers away from possible. A focused component with escape hatches beats a swiss-army knife with a changelog of regressions.
  • Zero runtime dependencies where possible. Every dependency is a future breaking change you didn't schedule. Date math that a library could do in one line was worth writing by hand to keep the tree clean.

Semver is a promise, so automate the promise

Nothing erodes trust in a package faster than a patch release that breaks builds. Version numbers are the only contract between you and people who will never read your changelog, and keeping that contract by hand doesn't survive contact with a busy week.

What earned its keep:

  • Conventional commits + automated releases. Commit messages drive the version bump. No 11pm judgment calls about whether a change is "really" breaking.
  • A public API surface test. One test file imports everything the package exports and exercises the documented prop combinations. If a refactor changes the public surface, CI fails before npm finds out.
  • Test against the React versions you claim to support. A peer-dependency range in package.json is a promise; a CI matrix is proof. The gap between the two is where angry issues come from.

Measure before you optimize the wrong thing

I assumed users would care most about bundle size, so I spent early effort tree-shaking and shaving kilobytes. The actual complaints? Timezone edge cases and keyboard accessibility. Not one issue ever mentioned size.

The lesson generalizes: an open source package is a product, and product intuition without usage data is just guessing. The issues tab, npm download curves after each release, and which StackBlitz examples get forked — that's your analytics stack. Read it before choosing what to build next. My guesses about what mattered were wrong often enough that I stopped trusting them.

Ship it before it's ready

The version I published was missing features I considered essential. Users disagreed — they adopted it for the core scheduling flow and asked for things I'd never predicted. The roadmap that emerged from real usage looked nothing like the one in my head.

There's a psychological trap in open source where you keep polishing privately because publishing feels like a final exam. It isn't. A 0.x version number is an explicit license to iterate, early adopters of niche packages are the most forgiving users you will ever have, and the standard they actually hold you to is "responds to issues," not "shipped perfect."

If you have an internal component you're proud of: extract it, type it properly, write the five-minute README, and publish. The feedback loop from real users teaches API design faster than any amount of internal polish — and the version of the package you'd have built alone, in private, is never the one people actually needed.