Six ways a full root pnpm workspace broke my polyglot monorepo
Pulling four mature JS apps into one root pnpm workspace with a shared lockfile failed for six concrete reasons; here's the narrower shape that survived.
Six ways a full root pnpm workspace broke my polyglot monorepo
I tried to pull four mature JavaScript apps into one root pnpm workspace with a single shared lockfile. It failed for six documented reasons.
Failed workspace boundary
The repo had grown into a polyrepo of disparate apps sharing a root directory but not much else: a Nuxt SSR site, an Ionic PWA, a Nuxt admin app, a Payload CMS, and a Spring Boot backend, with a new React Native slice about to land on top.
Three pain points kept surfacing:
- Type duplication. A large entity type was hand-copied across two of the frontends, thousands of duplicated lines that drifted on every schema change.
- Per-frontend API client. The Orval API client was configured inside each frontend independently, so the generated types diverged.
- Path-coupled delivery. Deploy scripts, GitLab CI jobs, and root task definitions all hard-coded today’s top-level folder names.
The target shape was the conventional one: a single apps/ + packages/ monorepo under one root pnpm workspace, with one shared lockfile and a unified dependency graph.
Surviving architecture
The failed experiment: one root workspace, one shared lockfile
Before any directory renames, I pulled the four mature JavaScript apps into one root pnpm workspace with a single shared lockfile. The apps’ own source and tsconfig files stayed untouched.
Six issues surfaced:
- Unchanged app code and tsconfig were not enough. The regressions came from dependency resolution, not from source changes. Existing apps started failing typecheck even though their own configs hadn’t moved.
- A shared lockfile changed the effective peer graph. Matching the direct
package.jsonversions to known-good values was insufficient once Nuxt, Vite, Ionic, Vue Router, and TanStack Query were all resolved together inside one shared root graph. Peers that had coexisted in separate lockfiles stopped coexisting. - One app’s framework context leaked into another’s quality gate. The
e2e-testspackage aliases@/*into one of the web apps’ source trees. A type regression in that web app surfaced insidee2e-testsand then blocked the new mobile slice’s aggregate checks, a failure in app A that broke CI for app B. - Subdirectory installs discover the parent workspace. A plain
pnpm installrun from a subproject under the monorepo root can discover the parent workspace and hydrate a different dependency graph than the app’s own unless the app is isolated. - Root overrides and linker changes create repo-wide blast radius. Once everything shared the root graph, fixing one app’s transitive issue frequently changed another app’s framework runtime or typing context. A targeted override stopped being targeted.
- The legacy apps don’t move at the same pace. Some apps are stable and should stay on proven dependency graphs; the new mobile slice needs freedom to evolve quickly. Forcing both into the same pnpm workspace couples their upgrade cadence (the cost is justified only when the apps are meant to move together).
The narrower workspace shape that survived
I backed out of the full-root workspace and kept the pnpm workspace additive and narrow: only the new mobile slice (app + BFF), one shared package, and the e2e-tests workspace sit inside it. The mature apps stay as standalone pnpm projects with their own lockfiles and package-local dependency graphs.
repo-root/
├── apps/
│ ├── mobile/ ← new RN slice (IN workspace)
│ ├── mobile-api/ ← new BFF (IN workspace)
│ ├── ssr-site/ ← mature, standalone (NOT in workspace)
│ ├── admin/ ← mature, standalone (NOT in workspace)
│ ├── pwa/ ← mature, standalone (NOT in workspace)
│ └── cms/ ← mature, standalone (NOT in workspace)
├── packages/
│ └── shared/ ← first shared TS pkg (IN workspace)
├── e2e-tests/ ← shared test workspace (IN workspace)
└── pnpm-workspace.yaml ← narrow: only the four IN-workspace paths
The workspace file encodes that boundary:
# pnpm-workspace.yaml — narrow scope only
packages:
- "apps/mobile"
- "apps/mobile-api"
- "packages/*"
- "e2e-tests"
Standalone apps under the repo root keep their isolation in CI with --ignore-workspace, so a subdirectory install can’t accidentally discover the parent root:
# CI for a standalone app — prevent root-workspace discovery
pnpm install --ignore-workspace --frozen-lockfile
| Concern | Full-root workspace (failed) | Narrow workspace + standalone apps (kept) |
|---|---|---|
| Lockfile count | 1 shared | 1 per mature app + 1 for the narrow slice |
| Peer-graph blast radius | repo-wide | contained per app |
| Upgrade cadence | coupled across all apps | per-app, independent |
| CI cache keys | one root key | app-local keys, preserved |
| Migration risk | big-bang, all-or-nothing | additive, recoverable |
A repo-wide pnpm workspace is now treated as a later migration, not a prerequisite. The only condition to revisit it: mature apps are structurally migrated, their CI/deploy model converges, and framework/toolchain drift is reduced.
mise as the orchestrator across the boundary
mise is what holds the “small workspace + standalone apps” shape together. Its config_roots setting treats each subdirectory as an independent task root, so one root mise.toml can fan tasks out per-project and aggregate them (test:all, lint:all, dev:mobile) without pretending the projects share a dependency graph.
# root mise.toml
# each subdir is its own task root
config_roots = [
"apps/mobile",
"apps/mobile-api",
"apps/ssr-site",
"apps/admin",
"apps/pwa",
"apps/cms",
]
[tools]
node = "22"
pnpm = "10"
The monorepo boundary is wider than the pnpm workspace boundary, and mise is the layer that spans both. pnpm owns dependency resolution inside each boundary; mise owns orchestration across them.
Verification
- The new mobile slice landed under
apps/without touching legacy CI or deploy paths. - Mature apps kept their proven standalone dependency graphs and existing CI cache keys.
- A staging environment proved the additive path before any broad rename or workspace-wide disruption.
- The failed experiment was throwaway; the additive-first approach left the production tree untouched.
- The pnpm workspace boundary is smaller than the conceptual monorepo boundary, and that’s deliberate.
Decision rule
- Validate the “one shared lockfile” assumption on a single app first. I committed all four mature apps into the shared graph before checking install + typecheck on one. The peer-graph regressions only appeared at scale, and a single-app dry run would have surfaced gotchas 1, 2, and 5 in isolation instead of all at once.
References
- pnpm workspaces: workspace discovery rules and the
--ignore-workspaceflag that keeps standalone apps isolated under a repo root. - pnpm catalog: how to unify shared versions inside one workspace. I wrote about applying it to an additive package in Unifying a pnpm monorepo’s dependency versions with catalog.
- mise configuration / config_roots: per-project task roots, the mechanism that lets one root
mise.tomlorchestrate projects that don’t share a dependency graph. - Orval: the OpenAPI-to-TanStack-Query client generator whose per-frontend config motivated centralizing
packages/api-clientlater.
This post was written with AI assistance.