What the two dependency models are and how Nx sees them

Under a single version policy, the workspace has one root package.json and one lockfile. Every third-party package resolves to exactly one version, and every app and library in the repo uses it. Upgrading a library is one change, reviewed and tested once against everything that imports it.

Under the per-project model, each app or library has its own package.json. The package manager's workspaces feature (npm, Yarn, or pnpm) links the projects together. Each project declares its own dependencies, so versions can drift: one service can stay on an older major release while another moves ahead. There is usually still a single lockfile at the root, but it may record several resolved versions of the same package.

Single version policy              Per-project manifests

  package.json + lockfile            lockfile (root)
           |                              |
   +-------+-------+              +-------+-------+
   |       |       |              |       |       |
 api    worker   lib-db         api/    worker/  lib-db/
                               package  package  package
                                .json    .json    .json
                                  \_____ workspace links _____/

Nx does not need per-project manifests to know who depends on what. It builds its project graph from two inputs: static analysis of source imports, and project configuration. When api imports a package, Nx records an edge from api to that external package, even if the package is declared only at the root. This is what makes affected-project detection work under a single version policy.

With per-project manifests, two systems shape the outcome:

  • The package manager's workspace linking decides which version each project actually resolves at install time.
  • Nx also reads those manifests as project metadata, alongside the import analysis.
 SINGLE VERSION POLICY               PER-PROJECT MANIFESTS

 +--------------------------+       +--------------------------+
 | root package.json        |       | root lockfile            |
 | root lockfile            |       | may record several       |
 | one version per package  |       | versions per package     |
 +--------------------------+       +--------------------------+
              |                                    |
    +---------+----------+              +----------+----------+
    |         |          |              |          |          |
    v         v          v              v          v          v
 +------+ +--------+ +--------+     +--------+ +--------+ +--------+
 | api  | | worker | | lib-db |     | api/   | | worker/| | lib-db/|
 |      | |        | |        |     |package | |package | |package |
 |      | |        | |        |     | .json  | | .json  | | .json  |
 +------+ +--------+ +--------+     +---+----+ +---+----+ +---+----+
                                        |          |          |
                                        +-- workspace links --+

     source imports                 source imports, project config,
     + project config               + per-project package.json
              |                                    |
              v                                    v
 +------------------------------------------------------------------+
 |  Nx project graph: project -> package edges, used for affected   |
 +------------------------------------------------------------------+

Left: one root manifest and lockfile serve every project. Right: each project has its own package.json, linked by workspaces. Nx builds its project graph from imports and config in both cases, and also reads per-project manifests.

Why a single version policy is the right default for backend and platform teams

With one root manifest, each dependency has exactly one declared version in the workspace. Upgrading a database driver, an HTTP framework or a logging library is a single diff to one package.json and one lockfile. Nx then uses its project graph to work out which projects consume that package. You review the change once, run the affected tests once, and either merge or revert it as a unit. You never find out two weeks later that three services still pin the old major version because their manifests were edited separately.

The common objection is that every deployed service would then carry the whole workspace's dependency list. That does not have to happen. Nx build tooling can generate a package.json for a built application that lists only the dependencies the app actually imports, with versions taken from the root manifest. The container image or deployment bundle stays small, and you still keep a single source of truth for versions.

Before you rely on this, confirm that your build executor supports it. Then inspect and test the generated output, because Nx infers the dependency list from static analysis. The cases most likely to be missed are:

  • Dynamic imports, where the module path is computed at runtime.
  • Configuration-loaded modules, such as plugins, transports or database dialects named in a config file.
  • Native modules and their optional platform-specific packages.

Run the built artifact with a clean install of the generated manifest in CI, not just in the workspace.

The model pays off most for teams running many services on a shared runtime and framework. In that setup, keeping versions aligned is already the goal, and drift between services is a real cost.

#!/usr/bin/env bash
# CI check: build one service, then install and run it using ONLY the
# package.json that Nx generated (generatePackageJson: true on the build target).
set -euo pipefail

APP=orders-api
OUT=dist/apps/$APP      # match your build target's outputPath
ENTRY=main.js           # match your executor's output file name

npx nx build "$APP"

# Copy the artifact out of the workspace so the root node_modules
# cannot hide a missing dependency.
WORK=$(mktemp -d)
cp -R "$OUT"/. "$WORK"
cd "$WORK"

# Log what static analysis decided this app needs.
node -e 'console.log(Object.keys(require("./package.json").dependencies || {}))'

# Clean install from the generated manifest only.
npm install --omit=dev --no-audit --no-fund

# Modules loaded by name from config, computed import paths, or native
# addons are easy for static analysis to miss. List them explicitly.
for mod in pg pino-pretty bcrypt; do
  node -e "require.resolve('$mod')" \
    || { echo "missing runtime dependency: $mod" >&2; exit 1; }
done

# Smoke test: start the service and hit its health endpoint.
PORT=3000 node "$ENTRY" &
PID=$!
trap 'kill $PID 2>/dev/null || true' EXIT
curl -fsS --retry 10 --retry-connrefused --retry-delay 1 \

echo "generated manifest for $APP is runnable"

A CI script that builds one Nx app, installs only its generated package.json in an isolated directory, checks that config-loaded and native modules resolve, and smoke-tests the result. The output path, entry file name and module list are placeholders to adapt to your workspace.

How dependency updates flow under each model

Under a single version policy, an upgrade is one change: the root package.json and the lockfile move together in a single PR. Nx reads the project graph, sees which projects import the changed package, and marks only those as affected. A typical flow:

  1. git switch -c bump-fastify
  2. npm install fastify@<new-version> at the workspace root
  3. npx nx affected -t lint test build --base=origin/main
  4. Open one PR. CI runs the same nx affected command.

The CI run is broad for a widely used package and narrow for a niche one. In both cases one green pipeline means the whole workspace works on the new version.

With per-project manifests, the same upgrade becomes N bumps, one per project that declares the dependency. Each lands when its owning team gets to it, so for weeks the lockfile can hold two or three resolved versions of the same package. Each PR is smaller, but nothing tests the combination. Library A on the new version might be fine, and app B importing A while still pinned to the old version might not be.

Dimension Single version Per-project manifests
PR volume One per upgrade One per project per upgrade
Lockfile consistency One resolved version Mixed versions during rollout
CI scope All dependents, in one run Each project in isolation
Team autonomy Low; upgrades are coordinated High; teams choose timing
Blast radius Wide, but caught before merge Narrow per PR, but surfaces later
Duplicate/singleton conflicts Rare Likely for libraries that expect a single instance
#!/usr/bin/env bash
# Single version policy: one branch, one manifest change, one PR.
set -euo pipefail

NEW_VERSION="${1:?usage: $0 <fastify-version>}"  # e.g. the target release you have vetted

# Start from an up-to-date main so the affected comparison is accurate.
git fetch origin main
git switch -c bump-fastify origin/main

# Bump the package in the root manifest only. The root package.json and
# package-lock.json change together, so the workspace has one resolved version.
npm install "fastify@${NEW_VERSION}" --save-exact

# Check that exactly one copy of fastify is resolved in the tree.
# Extra copies here are the duplicate-instance risk the policy is meant to prevent.
npm ls fastify --all

# List the projects Nx considers affected by the manifest and lockfile change,
# so reviewers can see the CI scope before it runs.
npx nx show projects --affected --base=origin/main

# Run the same targets CI will run, limited to affected projects.
npx nx affected -t lint test build --base=origin/main

# One commit and one PR. CI repeats the nx affected command against main.
git add package.json package-lock.json
git commit -m "chore(deps): bump fastify to ${NEW_VERSION}"
git push -u origin bump-fastify

A single-version upgrade flow in which one root manifest and lockfile change is checked for duplicate installs, scoped with nx show projects --affected, and verified with nx affected before opening one PR.

Configuring Renovate for an Nx workspace

Renovate reads the workspace as it finds it. With a single root package.json, each update touches one manifest and the lockfile, so a PR maps cleanly to a single version change across every project. With many workspace manifests, Renovate updates every manifest that declares the dependency. Where projects pin different ranges, the result can be several version lines moving at different speeds, which works against a single version policy unless you pin consistently.

Four decisions shape a conservative setup:

  • Group packages that must agree. Use packageRules with groupName for families such as a framework and its adapters, or an ORM and its CLI. A half-upgraded family fails in ways that per-package PRs never test.
  • Schedule noisy updates. Put type definitions, linters and frequently released tooling on a schedule window so they arrive in one batch rather than as a constant stream of PRs.
  • Separate majors. Match major in matchUpdateTypes with its own rule, without automerge, so breaking changes get a human reviewer.
  • Automerge patches only when CI earns it. Allowing automerge for patch is reasonable only if the required check runs nx affected against the correct base. That means a root lockfile change should mark everything as affected, which Nx does by default.

Nx itself is a special case

nx and the @nx/* plugins must share one version. Bumping them is also more than a version change. nx migrate rewrites configuration and source code through generators, and a plain bot bump skips those steps. For this reason, many teams add a packageRules entry that sets enabled: false for Nx packages and upgrade by hand:

  1. Run nx migrate latest.
  2. Install dependencies.
  3. Run nx migrate --run-migrations.
  4. Review the changes, then delete migrations.json.
{
  "$schema": "",
  "extends": ["config:recommended"],
  "packageRules": [
    {
      "description": "Move the ORM and its CLI together so a half-upgraded pair never lands",
      "matchPackageNames": ["prisma", "@prisma/client"],
      "groupName": "prisma"
    },
    {
      "description": "Batch noisy tooling into one weekly window instead of a constant PR stream",
      "matchPackageNames": ["@types/**", "eslint", "eslint-*", "prettier"],
      "groupName": "dev tooling",
      "schedule": ["before 6am on monday"]
    },
    {
      "description": "Majors get their own PRs and always need a human reviewer",
      "matchUpdateTypes": ["major"],
      "automerge": false
    },
    {
      "description": "Automerge patches only after required CI (nx affected) passes",
      "matchUpdateTypes": ["patch"],
      "automerge": true
    },
    {
      "description": "Nx upgrades go through: nx migrate latest, install, nx migrate --run-migrations, review. Listed last so it overrides the rules above.",
      "matchPackageNames": ["nx", "@nx/**"],
      "enabled": false
    }
  ]
}

A conservative renovate.json that groups related packages, schedules noisy tooling, keeps majors manual, automerges CI-passing patches, and disables Renovate for Nx packages so they are upgraded with nx migrate instead.

Enforcing the policy with lint rules and CI checks

A single version policy only holds if something checks it. Without enforcement, projects drift back toward their own versions one pull request at a time.

The dependency-checks rule

@nx/dependency-checks is an ESLint rule that runs against a project's package.json (parsed with jsonc-eslint-parser). It compares what the code imports, as seen in the Nx project graph, with what the manifest declares. It reports three kinds of problem:

  • Missing dependencies: the code imports a package the manifest does not list.
  • Obsolete dependencies: the manifest lists a package the code never imports.
  • Version mismatches: the manifest declares a range that conflicts with the root version.

Each check can be switched on or off, and ignoredDependencies handles packages that are loaded at runtime rather than imported.

Module boundaries

@nx/enforce-module-boundaries stops deep imports into another project's internals and enforces tag-based depConstraints. This matters for versioning. If a service imports a library's private files, it inherits that library's transitive dependencies without the graph recording the link. A later version split then breaks code that no check knew was connected.

The hoisting trap

When every package is installed at the root, Node resolves undeclared imports anyway. nx serve works, but an app built and deployed on its own can fail at startup with a module-not-found error.

To catch this in CI:

  1. Build each deployable app on its own, for example with nx build api.
  2. Enable generatePackageJson on executors that support it.
  3. Install from the generated manifest in a clean directory and run a smoke test.

The ESLint configuration below enables both rules.

When to avoid a single version policy, and the alternatives

Keep a single version policy as the default. Move a project to its own manifest only when one of three conditions applies:

  • The package is published to a registry. External consumers need a real dependencies and peerDependencies contract.
  • An app is pinned to a different runtime or framework major.
  • A service must lag during a migration.

Record each exception with an owner and an end date. Exceptions without an end date tend to become permanent.

The costs of one version for everything are real. A breaking major upgrade in a shared package blocks the whole workspace until every consumer is fixed. That stalls teams that never asked for the upgrade. Release cadence becomes coupled across teams as a result. Diverging for a single project means using overrides, local manifests or package aliasing. These mechanisms sit outside the normal update flow, so people forget them, and they surface later as duplicate installs or confusing resolution bugs.

The usual alternative is a hybrid setup:

  • The root manifest stays the source of truth for shared dependencies.
  • Narrow per-project manifests exist only for published libraries.
  • Those libraries declare peer dependency ranges that include the root versions, so local tests match what consumers install.

A hybrid needs the lint rules and CI checks described above. Without enforcement, every new project copies a local manifest, and the workspace drifts into per-project versions with none of the benefits of the per-project model.

Situation Recommended model Why
Internal services and libraries Single version One upgrade path, one lockfile to review
Library published to a registry Hybrid: narrow local manifest Consumers need declared dependencies and peers
App pinned to an older runtime or framework major Per-project manifest, documented Avoids blocking the rest of the workspace
One service lagging during a migration Temporary override with an end date Keeps the exception visible and bounded
Teams that need independent release cadence Hybrid, or separate repositories Shared versions couple release schedules

Impact on development cost and engineering time

The two models also differ in how review work grows as the workspace adds projects.

Illustrative estimate (assumed numbers, not a benchmark). Assume 20 projects (N) share 15 frequently updated packages (P), and each package releases once per update cycle.

  • Single version policy: update PRs ≈ P = 15 per cycle
  • Per-project manifests: manifest changes ≈ P × N = 15 × 20 = up to 300 per cycle, unless Renovate groups them

To get your own figures, substitute your project and package counts. This is not a measured result. Real numbers depend on release frequency, grouping rules and how many projects depend on each package.

Cost Single version policy Per-project package.json
Review load Grows with package count Grows with packages × projects
Bot PR handling Each PR gets a real review Fatigue leads to blanket approvals
Major upgrades Every affected project must be fixed before merge Long campaigns as projects drift across versions
Debugging One resolved version per package Duplicate installs, mismatched types, peer conflicts

The per-project model's costs build up slowly. When reviewers see dozens of near-identical bot PRs, they stop reading them. Those approvals let regressions through. The single-version cost comes all at once: one major-version PR can block until every consumer compiles. That work is visible and bounded, and it happens once.

Under a single version policy, most of the time savings come from four practices:

  • Grouping related packages into one PR.
  • Running nx affected so CI tests only the projects that changed.
  • Automerging patch releases once checks pass.
  • Using nx migrate for Nx and plugin upgrades, so codemods do the config rewrites instead of engineers.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nx single version policy, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.