Skip to main content

How I built a 10-package React library as a solo developer

The real story behind Tour Kit: 12 packages, 31K lines of TypeScript, and 141 commits in 3.5 months. Architecture decisions, failures, and what I'd change.

DomiDex
DomiDexCreator of Tour Kit
April 11, 202610 min read
Share
How I built a 10-package React library as a solo developer

How I built a 10-package React library as a solo developer

On December 22, 2025, I created a Git repo called tour-kit. Three and a half months later, it's 12 packages, 31,000 lines of TypeScript, and 141 commits. I built every line alone.

This isn't a success story. Not yet, anyway. Tour Kit hasn't launched publicly. Zero npm downloads. No GitHub stars.

I'm writing this while the packages still sit unpublished in a monorepo that only I have ever cloned.

But building it taught me things about package architecture, monorepo tooling, and solo-developer tradeoffs that I couldn't have learned any other way. This is the honest version of that story.

npm install @tourkit/core @tourkit/react

The starting point: I got annoyed at React Joyride

I was adding product tours to a client project in late 2025. React Joyride was the obvious pick. 603K weekly downloads. Battle-tested. Everyone uses it.

Then I looked at the bundle. 37KB gzipped for the core package, with Popper.js bundled in, and a styling system that fought Tailwind at every turn. I spent more time overriding Joyride's CSS than writing the actual tour steps. The tooltip wouldn't match our design system no matter how many !important declarations I threw at it.

I checked Shepherd.js. AGPL licensed. That's a non-starter for most commercial projects unless you want to open-source your entire application.

Driver.js was closer to what I wanted. Small, headless-ish. But it hadn't been updated in months and the TypeScript types were partial at best.

So I did what every developer with more ambition than sense does. I started from scratch.

The first month: getting core right

The first version of Tour Kit was a single package. One package.json, one tsconfig.json, one entry point. It exported a useTour hook and a TourProvider component. About 800 lines of code.

It worked. Sort of. The positioning logic was tangled with the React rendering logic, which was tangled with the step sequencing logic. Adding a feature meant touching three files that shouldn't have known about each other.

I rewrote it into two packages by mid-January: @tour-kit/core for the framework-agnostic logic and @tour-kit/react for the React bindings. This was the first real architectural decision, and it shaped everything that came after.

The split forced me to think about what "core" actually meant. Step sequencing? Core. Position calculations? Core. Keyboard navigation? Core.

React context? Not core. JSX components? Not core. If it needed import React, it didn't belong in @tour-kit/core.

That boundary was hard to draw at first. I kept finding React-isms hiding in places they shouldn't be. But once the line was clean, adding new functionality got dramatically easier. Each new package only needed to depend on @tour-kit/core and could ignore the React rendering layer entirely.

Key decisions that shaped the architecture

Three choices defined the project more than any individual piece of code.

Choice 1: Turborepo over Nx. I tried Nx for a weekend. The configuration surface area was enormous for a single developer. Nx assumes you have a team that benefits from computation caching across CI machines. I needed something simpler.

Turborepo's turbo.json is 36 lines. That was the entire build configuration. I set concurrency: 20 and stopped thinking about it.

Choice 2: tsup for every package. I considered Rollup, Vite library mode, unbundled TypeScript output. tsup won because it produces ESM + CJS dual output with .d.ts declarations from a 10-line config file. Every package uses the same tsup.config.ts template. When a bundler bug hits one package, the fix applies to all of them. As of April 2026, tsup handles the "exports" field in package.json correctly for both Node and browser consumers, which is harder than it sounds.

Choice 3: headless-first, always. Every component in Tour Kit ships without styles. No CSS files, no CSS-in-JS, no inline styles. You bring your own <div> and your own classes. This was a bet that developers using Tailwind and shadcn/ui would rather write their own 15 lines of JSX than fight 500 lines of someone else's CSS. It also meant the core package ships at under 8KB gzipped, because there's nothing to style.

The headless approach also solved a problem I didn't anticipate: theming. React Joyride users open dozens of GitHub issues about customizing the tooltip's appearance. Tour Kit doesn't have a tooltip. You render whatever you want. That's zero theming bugs by design.

What went wrong

Plenty.

I built packages nobody asked for. By February, I had 10 packages. Core, React, hints, adoption, analytics, announcements, checklists, media, scheduling, surveys. Every time I thought "someone might want this," I created a new package with a new package.json, new build config, new TypeScript project references.

The surveys package alone has 5 question types (NPS, CSAT, CES, multiple choice, open text), a scoring engine, fatigue prevention, and context awareness. That's an entire product. I spent two weeks building it before a single person had installed @tour-kit/core.

Looking back, the correct first release was core + react + hints. Three packages. Everything else could have waited for actual user feedback.

TypeScript strict mode caught bugs. It also tripled my development time. I run strict: true with noUncheckedIndexedAccess, exactOptionalPropertyTypes, and every other flag that makes TypeScript complain. Real bugs get caught. But every generic type, every conditional type, every infer clause takes three times longer to get right.

Here's what the hook composition patterns in @tour-kit/core look like:

// packages/core/src/hooks/use-tour.ts
export function useTour<TStepData extends Record<string, unknown> = Record<string, unknown>>(
  config: TourConfig<TStepData>
): TourInstance<TStepData>

That generic parameter flows through every hook, every context provider, every callback. Getting the types to propagate correctly through TourProvideruseTour()useStep()onStepComplete took three full days. Worth it? You get autocompletion on your custom step data everywhere. But three days for type plumbing on a solo project is a lot.

Biome replaced ESLint, and it wasn't painless. I switched from ESLint + Prettier to Biome around commit 30. Biome is fast (200ms for the whole monorepo) and the configuration is simpler. But the import ordering rules fight with TypeScript path aliases, the cognitive complexity warnings flagged functions that were genuinely complex and needed to be, and I spent a non-trivial amount of time adding // biome-ignore comments to code that was correct.

I'd still choose Biome again. The speed difference in a monorepo with 12 packages is real.

What worked

The @tour-kit/core boundary held. After three months and thousands of lines of code, no React import has leaked into core. Every new package (surveys, scheduling, adoption) depends only on core's types and utilities. January's boundary survived every feature addition.

Why does that matter? If I ever want Vue or Svelte adapters, the core logic is already framework-agnostic. Not a hypothetical requirement. Just a constraint that happens to produce better architecture even if no one ever writes @tour-kit/vue.

Bundle budgets caught regressions early. Hard limits: core under 8KB gzipped, react under 12KB, hints under 5KB. Turborepo runs size as a build task. Every time I added a dependency or utility, the budget told me immediately whether it was worth the bytes.

One example: the analytics package nearly blew its budget when I added a batching queue for events. Instead of importing a third-party queue library, I extracted the queue into a 300-byte utility in core. That kind of decision only happens when there's a number on the dashboard yelling at you.

Testing with Vitest and Testing Library was fast enough to not skip. The monorepo runs Vitest 4.1 with happy-dom. Full test run across all packages takes under 8 seconds. That's fast enough that I actually run tests before committing, which is the bar that matters for a solo project.

According to the 2025 State of JS survey, Vitest now has higher satisfaction scores than Jest. No per-package config. No transform issues.

Changesets made versioning mechanical. I use @changesets/cli with linked versioning across all packages. When I bump core, every package that depends on core bumps too. The changeset file is a Markdown description of what changed. After three months, the changelog is a readable history of every decision. As Bits and Pieces documented, linked versioning in monorepos prevents the version drift that causes "works on my machine" bugs when consumers install mismatched versions.

The numbers (honest ones)

MetricValue
Packages12 (core, react, hints, adoption, analytics, announcements, checklists, media, scheduling, surveys, ai, license)
Total lines of TypeScript31,043
Commits141
Time spanDec 22, 2025 to Apr 11, 2026 (~3.5 months)
npm downloads0 (pre-launch)
GitHub stars0 (pre-launch)
Core bundle sizeUnder 8KB gzipped
Runtime dependencies0
Build tool config (turbo.json)36 lines

Zero downloads. Zero stars. That table is uncomfortable to publish, but it's the truth. I built a 12-package monorepo before a single developer had a reason to install it.

What I'd do differently

Ship core + react first, everything else later. The ten-package architecture is sound. Building all ten packages before launch was not. I should have published core and react in January, gotten feedback, and let actual users tell me which extended packages to build first.

The GitHub Open Source Survey found that 93% of open source contributors say responsiveness to user feedback matters most for project success. I had zero feedback loop for three months.

Use AI more carefully. I used Claude Code (this tool, yes) for parts of the implementation. AI-generated code ships faster but needs more careful review. A GitClear study found that AI-assisted code produces 1.7x more issues and 2.74x more security vulnerabilities than human-written code. I caught several type-safety gaps in generated test files that looked correct but tested the wrong behavior. Solo developers don't have a code review safety net, which makes AI-generated code riskier, not safer.

Write the documentation site earlier. I started the docs site (Fumadocs + Next.js) around commit 80. It should have been commit 10. Writing docs forces you to articulate the API from the user's perspective. Every time I wrote a docs page, I found an API that was awkward or inconsistent. If I'd documented the API before building the extended packages, I would have caught design problems at week 2 instead of month 3.

What's next

Tour Kit is publishing to npm soon. Core packages are ready. Docs cover all 12 packages. Tests run green.

But "ready to publish" and "ready for users" are different things. I'm still working through the launch checklist: license validation through Polar.sh, example apps for Vite and Next.js, and a pricing page that doesn't feel like SaaS theater. (If you're curious about the pricing decision, I wrote about that separately.)

Building a 12-package React library solo in 3.5 months taught me one clear thing: the architecture decisions you make in week one compound for months. The core/react split, the headless approach, the bundle budgets. Those early choices made every subsequent package easier to build. The late choices (building surveys before anyone asked, AI-generated code without code review, no docs until month 2) cost real time.

If you're starting a similar project, here's the version of this article I wish I'd read:

  1. Ship the smallest useful thing first
  2. Set bundle budgets from day one
  3. Draw the framework-agnostic boundary before writing a single component
  4. Write docs before you think you need them
  5. Get human eyes on your code, especially the AI-generated parts

Tour Kit's source is at github.com/DomiDex/tour-kit. The docs are at usertourkit.com. If you're building product tours in React and want a headless library that doesn't fight your design system, give it a look.

npm install @tourkit/core @tourkit/react

Internal linking suggestions:

Distribution checklist:

  • Hacker News: "Show HN: I built a 12-package React tour library solo in 3.5 months" (building-in-public angle)
  • Reddit r/reactjs: Focus on the headless architecture and TypeScript decisions
  • Reddit r/SideProject: Solo developer story angle
  • Indie Hackers: Building-in-public with honest numbers (zero downloads disclosure)
  • Dev.to: Full cross-post with canonical URL
  • Twitter/X: Thread format — one tweet per section with the metrics table as the hook

Ready to try userTourKit?

$ pnpm add @tour-kit/react