Designing a pnpm Monorepo That Publishes an npm Org
There is a particular kind of complexity that does not show up in any feature list. It is the work of running a monorepo that publishes multiple packages cleanly, keeping their versions sane, and making sure the types they share never quietly drift apart. Re-Shell is that kind of project. It is a pnpm monorepo that publishes an npm organization, and most of the engineering I am proud of there is invisible to anyone using it. This is a post about that invisible part, because it is where solo maintainers usually get burned.
Why pnpm workspaces
The repo is a pnpm workspace, and that choice was not casual. pnpm's content-addressable store and strict node_modules layout matter more in a multi-package repo than people expect. The strictness is the point. It refuses to let a package import something it did not declare as a dependency, which means a package cannot accidentally lean on a transitive dependency that happens to be hoisted into place. In a repo where three packages are meant to be publishable on their own, that discipline is what keeps them genuinely independent rather than secretly coupled through the install layout.
The workspace also makes local development honest. When the CLI depends on contracts, it resolves to the workspace version, so a change to the shared types is reflected immediately across the repo without a publish step. That tight inner loop is most of the reason a monorepo is worth the overhead at all.
The packages layout
The structure is deliberately plain. There is a packages directory, and inside it three packages: cli, ui, and contracts. That is the whole story, and the flatness is intentional. I have seen monorepos grow elaborate nested hierarchies that mostly serve to make navigation harder. Three packages, one level deep, each with a clear job.
The CLI is the tool people run. The UI is the Shadcn-first component library. Contracts is the shared TypeScript boundary, and it is the keystone. Both the CLI and the UI depend on contracts, and neither depends on the other. That dependency shape is not an accident. It means the only thing the two consumer packages have in common is the agreed-upon types, which is exactly the relationship I want.
Contracts as the thing that cannot drift
If I had to name the single most important design decision, it is making contracts a real, separately versioned package instead of a folder of types copied into each consumer. The whole failure mode I was designing against is drift. The UI evolves a slightly different idea of a shape than the CLI generates, nobody notices because both compile fine in isolation, and the mismatch surfaces as a runtime bug weeks later.
By putting the shared types in one package that both sides import, drift becomes a compile error instead of a production incident. If I change a contract, every consumer that has not caught up stops type-checking. That is the loud, early failure you want. It moves the cost of a breaking change to the moment you make it, which is the only moment it is cheap to fix.
Versioning and release discipline
Publishing multiple packages from one repo sounds simple until the first time you ship a half-finished release. The discipline I hold to is that contracts leads. If a change touches the shared types, contracts gets versioned first, and the consumers bump to depend on the new version in the same release. The CLI and UI never ship a version that references a contracts release that does not exist yet.
I keep the versioning conventional and boring. A breaking change to a published type is a major bump on contracts, full stop, even if it feels minor, because someone downstream may be pinned to it. The temptation as a solo maintainer is to wave that away since you control all the consumers in the repo. But the npm org makes the packages public, and the moment they are public you no longer fully control who depends on what. Treating the published surface as a real contract, even when it is mostly just you, is what keeps it trustworthy later.
The friction of running an org alone
Running an npm organization as a solo maintainer has a specific texture to it. Every published package is a small ongoing obligation. The scoped naming under the org is clean and signals that the packages belong together, which I like, but it also means three separate publish steps, three changelogs to keep honest, and three places a mistake can leak out. There is no second reviewer to catch a botched release. The CI/CD wiring exists partly to be that second reviewer, running checks and the build before anything goes out.
This is also where AI coding agents earn their place. The mechanical parts of release hygiene, drafting changelogs from the diff, sanity-checking that version bumps are consistent across the three packages, catching the contract change that should have triggered a major bump, are exactly the things I would otherwise do at the end of a long day and get subtly wrong. I lean on Claude for that kind of review pass. It does not replace the judgment about what a release should contain, but it is a reliable extra set of eyes on the tedious parts where solo maintainers slip.
What I would tell anyone doing this
Keep the package count small until you genuinely need more. Make your shared types a first-class package and treat its published surface as a contract from day one. Decide your release ordering and never break it. The monorepo tooling is the easy part. The hard part is the discipline that keeps three packages from quietly growing apart, and that discipline is mostly a set of rules you commit to before it is convenient.