← All writing

Field note / 2026

Why the single package.json monorepo policy is a trap

A single root package.json can look simpler in an Nx monorepo but dependency hoisting hides ownership and can turn upgrades into confusing production build failures.

At work my team manages a huge TypeScript Nx monorepo. It’s got a bit of everything like Angular, Next.js, Express and some legacy raw Webpack frontends.

Because the repo has been around since the Nx v15/16 days it follows the classic “single package.json” policy. If you aren’t familiar this means every single dependency for every app and library lives in the root package.json.

Back then it looks like the Nx team and a lot of devs pushed hard for this. The idea does sound good to me too at first. It means no duplicate declarations and everyone stays on the same version. The repo is supposedly healthier and easier to reason about because of that.

Nowadays more modern tools like Turborepo and pnpm have been pushing for the exact opposite which is declaring every single dependency at the project/app level instead of the workspace root.

When people first hear about the app-level approach they immediately think duplication… If you have 10 React apps that means you’ll have 10 duplicate react and react-dom declarations across the repo.

But that explicitness is totally intentional. As they say a feature not a bug. There are huge benefits to doing it this way such as:

  1. You know exactly what an app uses just by looking at its local package.json
  2. The app uses packages from its own node_modules not the root folder that hoists
  3. You can upgrade one app without being blocked by another team that isn’t ready to upgrade yet
  4. If you need to containerize an app you don’t have to prune a huge root package.json. You just run a clean install for that app’s dependencies which makes the Docker image much smaller

This brings me to the problem I ran into today.


The ESlint v9 Migration and Zod v4

We wanted to migrate one of our Next.js apps to Next.js v16.

Right off the rip we hit a blocker because the new eslint-config-next requires ESLint v9. The problem is our monorepo root uses ESLint v8 because other legacy apps would break on v9 in addition to being a huge piece of work to migrate the entire monorepo to ESlint v9.

To bypass this we moved the Next.js packages (next and eslint-config-next) out of the root and into the app’s local package.json. We also moved ESLint and its plugins locally and bumped them to v9 so they are compatible with Next 16’s eslint config package.

Linter ran fine but the app’s build started crashing.

The app uses Zod v3. Zod was declared in the root package.json so the app was just been implicitly using it via hoisting.

But when we moved ESLint v9 into the app’s local package.json ESLint v9 brought along its own internal Zod v4 dependency. Because ESLint was now local, Zod v4 got nested right where our app code could see it and it was ‘closer/higher’ than the root v3 so the app started resolving Zod v4 instead of the root’s Zod v3…

Since Zod v4 has a different API than v3, our code broke.

It felt completely insane at first because we didn’t touch a single line of Zod code in the entire PR (or anything prod/runtime related). The error looked completely unrelated and tracking it down was a headache. We ran npm why (and a few similar commands) and finally realized the app was accidentally sort of hijacking ESLint’s Zod v4 and using it in production.


The Takeaway

The fix was straightforward in that we just explicitly added Zod v3 to the app’s local package.json so it stopped looking at ESLint’s internals since now it was hoisted ‘higher/closer’.

But this whole situation proved a point. Conventional wisdom would tell you fewer package declarations make a repo easier to reason about. It’s just not true at scale. It hides things behind dependency hoisting and causes bizarre bugs (like the one above) that are difficult to debug.

Explicit tends to always be better than implicit even it means more code.

The only things that belong in a monorepo root are tools used to manage the workspace itself so things like knip, turbo, or rimraf. Everything else and even things like Prettier or ESLint configs should live down at the app level where they actually get used.