What Nx adds to an existing repository before any restructuring

Nx is a build system that sits on top of the tools you already use. In its core form it gives an existing repository three things: a task runner with local caching, a graph of projects and tasks, and the ability to run only the tasks affected by a change. The Nx introduction describes it as modular. You can start with the CLI and adopt other capabilities later.

Each piece solves a specific problem. Caching stores task results keyed on their inputs, so a test suite or build whose inputs have not changed is replayed rather than re-run. The project and task graph makes dependencies between packages explicit, which lets Nx order tasks correctly and run independent ones in parallel. Affected execution uses that graph and the files changed in a commit to skip work that cannot be impacted by the change.

None of this requires moving code. Nx can read your existing package.json scripts and workspace layout, so the first step changes how tasks run, not where files live. That is why it carries little risk. If caching produces wrong results or the graph misses a dependency, you can find out and fix it before any directory has moved. Backing out is cheap at this stage.

The teams that gain most are backend and platform groups with several related repositories, libraries shared between services, or changes that regularly span repos. For them, a correct dependency graph is the foundation that every later migration step depends on.

+--------------------------------------------------------------+
|               Existing repository (unchanged)                |
|  package.json scripts   workspace layout   source files      |
+--------------------------------------------------------------+
                               |
                               | read in place, no files moved
                               v
+--------------------------------------------------------------+
|                       Nx (added layer)                       |
|                                                              |
|  +----------------+   +------------------+   +------------+  |
|  | Project & task |-->| Affected         |-->| Task runner|  |
|  | graph          |   | execution        |   | + local    |  |
|  | (dependencies, |   | (changed files ->|   | cache      |  |
|  |  task order)   |   |  impacted tasks) |   | (by inputs)|  |
|  +----------------+   +------------------+   +------------+  |
+--------------------------------------------------------------+
                               |
              +----------------+----------------+
              |                                 |
              v                                 v
      +---------------+                 +---------------+
      | Cache hit:    |                 | Cache miss:   |
      | replay result |                 | run task,     |
      +---------------+                 | store result  |
                                        +---------------+

Nx reads the existing repository in place to build a project and task graph, uses it to select only affected tasks, then runs them through a task runner that replays cached results when inputs are unchanged.

Running nx init in place and validating the cache

Run npx nx init at the repository root on a dedicated branch. Nx detects your package manager, any workspace configuration, and the scripts in each package.json. It then asks which scripts produce cacheable results and which must run after the same script in dependent projects. Your answers go into nx.json as targetDefaults. Nx is added as a dev dependency, and the local cache directory is added to .gitignore. Depending on the repository layout and Nx version, it may also rewrite existing scripts so they invoke Nx. Your existing tooling stays in place: Nx orchestrates the commands you already have.

Review the diff before committing. Confirm that only deterministic tasks such as build, test and lint are marked cacheable. Exclude anything that deploys, publishes or depends on network state.

Then make inputs and outputs explicit. Inputs decide when a cache entry is valid. Outputs decide what gets restored on a hit. Use named inputs such as {projectRoot}/**/* to cover source files, and ^production to include upstream projects. Also list anything the task reads from outside the project root, such as a shared tsconfig.base.json or a relevant environment variable. Set outputs to the real artifact paths, for example {projectRoot}/dist.

To verify, run a build twice. The second run should report that it was read from the cache. Then edit a file the task depends on and confirm the next run is a cache miss.

Validation matters because the failure mode is silent. If an input is missing, Nx returns a cache hit with stale artifacts after a real change. If an output is missing, a cache hit replays the logs but leaves the files absent.

# On a dedicated branch, let nx init detect scripts, then review its changes.
git switch -c chore/nx-init
npx nx init
git diff --stat && git diff nx.json package.json .gitignore

# Make cache inputs and outputs explicit for build (placeholder paths).
cat > nx.json <<'EOF'
{
  "namedInputs": {
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json", { "env": "NODE_ENV" }],
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": ["default", "!{projectRoot}/**/*.spec.ts"]
  },
  "targetDefaults": {
    "build": {
      "cache": true,
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"],
      "outputs": ["{projectRoot}/dist"]
    }
  }
}
EOF

# Run twice: the second run should be reported as read from the local cache.
npx nx build my-app
npx nx build my-app

# Outputs check: delete the artifacts, then confirm a cache hit restores them.
rm -rf apps/my-app/dist
npx nx build my-app
ls apps/my-app/dist

# Inputs check: change the content of a source file; this run must be a cache miss.
echo '// cache check' >> apps/my-app/src/main.ts
npx nx build my-app
git checkout -- apps/my-app/src/main.ts

This example runs nx init on a branch, reviews the generated diff, and declares explicit build inputs and outputs in nx.json. It then checks the cache by confirming a repeat run is a hit, that deleted dist files are restored, and that a real source edit causes a miss.

Configuring task pipelines and checking the project graph

With caching trustworthy, the next step is ordering. In nx.json, set targetDefaults so the build target declares "dependsOn": ["^build"]. The caret means "run build on every project this one depends on first." Without it, Nx may build a service before the shared library it imports, and the result depends on scheduling luck. Because the rule lives in targetDefaults, it applies to every project. You don't have to repeat it in each project.json, and new projects inherit it automatically. Add the same pattern for test or lint only if those tasks consume built artifacts from upstream projects. Otherwise you serialize work that could run in parallel.

Next, run nx graph and read the edges carefully. Nx infers dependencies from package.json entries and from TypeScript or JavaScript imports, and affected detection relies entirely on this graph. A missing edge means a change to a library will not trigger tests in the service that consumes it, and CI will pass when it should have failed. Check that every edge matches what you know about the code. In particular, look for runtime-only relationships such as a service that calls another over HTTP or reads a generated schema.

Non-JS services need more manual configuration. A Go or Python service wired up through generic nx:run-commands targets gives Nx no imports to parse. It has no inferred dependencies and no inferred inputs or outputs, so you must declare all three yourself. Use implicitDependencies in project.json to add the edges Nx cannot see. A language plugin, where one exists, can infer some of this for you. Even with a plugin, check the graph afterward to confirm the inferred edges are correct.

# nx.json: every build waits for the builds of its upstream projects (^)
cat > nx.json <<'EOF'
{
  "targetDefaults": {
    "build": { "dependsOn": ["^build"], "cache": true }
  }
}
EOF

# apps/billing-go/project.json: a Go service has no imports Nx can parse,
# so declare its inputs, outputs and upstream edges by hand
cat > apps/billing-go/project.json <<'EOF'
{
  "name": "billing-go",
  "implicitDependencies": ["api-schema"],
  "targets": {
    "build": {
      "executor": "nx:run-commands",
      "options": {
        "cwd": "apps/billing-go",
        "command": "go build -o ../../dist/apps/billing-go/server ."
      },
      "inputs": ["{projectRoot}/**/*.go", "{projectRoot}/go.mod", "{projectRoot}/go.sum"],
      "outputs": ["{workspaceRoot}/dist/apps/billing-go"]
    }
  }
}
EOF

# Open the interactive graph and confirm the billing-go -> api-schema edge exists
nx graph

# Print the resolved configuration, including inherited targetDefaults
nx show project billing-go --json

# Write the graph to a file so CI can diff it against the expected edges
nx graph --file=graph.json

Sets a workspace-wide build ordering rule in nx.json, declares inputs, outputs and an implicit dependency for a Go service built with nx:run-commands, and then inspects the project graph to confirm the edges.

Bringing in other repositories with nx import

With the first repository's graph confirmed, you can bring in other repositories. nx import merges a separate repository into the workspace and keeps its git history, so commits from the source repo appear under the destination directory. Choose that directory to match your existing layout, for example apps/ for deployables and libs/ or packages/ for shared code. Moving a project after import adds rename noise to its history and breaks path-based CI rules.

After the import, run a fresh install. The imported lockfile and dependency ranges will rarely match yours, and conflicts belong in a reviewed commit of their own.

Approach Strengths Costs
Single root manifest One version of each package, simpler upgrades, one lockfile Forces version alignment up front
Per-project manifests Projects keep their own versions, so import is faster Duplicate installs, drift, harder audits

A single root manifest is usually the better end state. Forcing one version is not free, though. If one repo is on an older major release of a framework or test runner, aligning it means changing code: renamed APIs, new config formats, updated type signatures. Do that work in a separate change before or after the import, never inside it.

Preserved history helps with git blame and bisecting, but it grows the clone and clutters git log. A fresh copy with the old repo archived read-only keeps the monorepo lean, but blame stops at the import commit.

Import first the repository with the most shared code or the most frequent cross-repo changes. Then validate the graph, the cache, and CI before importing the next one.

# Run from the root of the existing Nx workspace, on a clean branch.
git checkout -b import-shared-utils

# Start with the repo that has the most shared code or cross-repo changes.
# Import its main branch into libs/ so it matches the existing layout
# and you never have to move it later (moves add rename noise to history).
npx nx import  libs/shared-utils --ref=main

# Check that the source repo's commits now show up under the new directory.
git log --oneline -- libs/shared-utils | head -n 20

# Reinstall from the root so a single lockfile covers the imported code.
npm install

# Show duplicate or conflicting versions of a package you share,
# for example a test runner that both repos depend on.
npm ls jest

# Commit the dependency and lockfile changes on their own so they get reviewed.
# Do any code changes needed to align versions in a separate commit or PR.
git add package.json package-lock.json
git commit -m "chore: reconcile dependencies after importing shared-utils"

# Before importing the next repo, check the graph, the cache and the targets CI runs.
npx nx graph
npx nx run-many -t lint test build
npx nx run-many -t lint test build   # a second run should come from the cache

This example imports one repository with its history into libs/ using nx import, reinstalls from a single root lockfile, commits the dependency changes separately, and checks the graph, CI targets and cache before the next import.

Updating CI to run only affected tasks

Once the graph is accurate, switch CI from running every target to nx affected. Nx compares two commits, maps the changed files to projects, and runs the requested targets only for those projects and the projects that depend on them. The --base and --head flags, or the NX_BASE and NX_HEAD environment variables, set which two commits it compares.

The base is where most mistakes happen. For a pull request, use the merge-base with the target branch, not the target branch's current tip. Otherwise, changes that landed on main after the branch was cut get counted as part of the PR. For main-branch builds, use the last commit that passed CI. Don't use HEAD~1: if a push contained several commits or the previous run failed, HEAD~1 drops changes that were never verified. Both cases need git history, so turn off shallow cloning. Most CI checkout steps fetch a depth of 1 by default, and with that little history Nx can't find the merge-base.

affected is only as good as the graph behind it. If a project reads a file that isn't listed as an input, or loads code at runtime that Nx can't see, changing that file won't trigger its tasks. Nx won't warn you, and the result looks like a passing build. To guard against this, declare shared config files in namedInputs and add implicitDependencies for links Nx can't detect. Also schedule a regular full run with nx run-many to catch anything affected missed.

Beyond CI itself, revisit how access works. In separate repositories, CI permissions, code ownership rules, and release and versioning processes were each scoped to one repo. A monorepo puts all of that access in one place and widens the blast radius of a bad change, so redesign these processes on purpose instead of copying each repo's setup over.

Adding module boundary rules once the graph is trustworthy

Boundary rules are only as accurate as the project graph they check. The @nx/enforce-module-boundaries lint rule reads that graph to decide whether an import crosses a forbidden line. Right after nx init or nx import, the graph usually still has gaps: path aliases that don't resolve yet, dependencies wired through scripts rather than imports, and projects that were moved but not yet named consistently. If you enforce rules against that graph, you get false failures. Engineers then silence them with disable comments, and the rule loses credibility before it has done anything useful. Wait until nx graph matches what the team believes the dependencies are, and until affected runs in CI have stopped surprising anyone.

Tag first, constrain second

Add tags to each project's configuration along two axes:

  • Ownership or domain, such as scope:billing or scope:shared.
  • Layer, such as type:app, type:feature or type:util.

Tagging changes nothing at runtime, so you can roll it out in small pull requests.

Then add constraints

Next, configure depConstraints in the lint rule. Each entry pairs a sourceTag with onlyDependOnLibsWithTags or notDependOnLibsWithTags. A typical first set says that type:util may depend only on type:util, and that each scope:* may depend only on itself and scope:shared.

Run lint across the workspace and fix or explicitly allowlist the existing violations before turning the rule into a CI gate. After that, every new cross-project dependency becomes a reviewed change to the constraints rather than a stray import.

Optional: adopt plugins

With the graph stable, this is also a reasonable time to adopt Nx plugins for your toolchain. Plugins can infer targets and their inputs, which replaces hand-maintained task configuration.

When to avoid Nx, alternatives, and impact on cost and engineering time

When Nx is the wrong choice

Nx pays off when projects share code and change together. It is a poor fit if:

  • You have a few loosely coupled repositories that rarely change at the same time. The project graph has little to optimize, and you still take on the operational cost of a monorepo.
  • Most of your stack is not JavaScript or TypeScript. Plugins can cover other languages, but if your toolchain already has a mature native build system, Nx adds a second layer to learn.
  • No one on the team can own the build configuration. Cache inputs, target defaults and plugin upgrades need regular attention. If they drift, you get stale cache hits, which are worse than slow builds.

Alternatives

Option Fits when Trade-off
Polyrepo with shared packages Teams release independently Version skew; cross-repo changes need several PRs
Package-manager workspaces only Small JS repo with simple scripts No caching or affected detection
Other monorepo build tools Polyglot code or hermetic builds required Steeper setup and stricter conventions
Fresh-start consolidation Legacy layout is beyond saving History and in-flight work are disrupted

Weighing cost against time saved

Illustrative estimate (assumed numbers, not a benchmark):

Monthly hours saved = (CI minutes saved per run × runs per month ÷ 60) + (hours per cross-repo change avoided × changes per month).

Assume 8 minutes saved per run, 600 runs per month, 3 hours per cross-repo change, and 4 such changes per month. That gives (8 × 600 ÷ 60) + (3 × 4) = 80 + 12 = 92 hours per month.

Now assume 120 hours to set up and redesign CI and releases, plus 10 hours per month of upkeep. The net saving is 82 hours per month, so setup is repaid in about 1.5 months.

If you get fewer than about 10 hours of savings per month, upkeep cancels out the gains.

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 migrate to nx monorepo, 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.