What Nx is and when a backend team should adopt it
Nx is a build system and task runner that models your repository as two graphs. The project graph records which projects exist and how they depend on each other. The task graph expands that into concrete work, such as "build api, which first requires building shared-db." Because Nx knows these edges, it can do three things a folder of shell scripts cannot do reliably. It can cache task results keyed on their inputs and skip work that has not changed. It can order tasks correctly while running independent ones in parallel. It can run only the projects affected by a given change (Nx introduction).
Who it fits
Nx fits best in JavaScript and TypeScript-heavy monorepos, where its plugins understand the tooling and can infer tasks without much configuration. It also suits platform teams that own shared build tooling for many product teams. For those teams, one consistent way to build, test and cache across dozens of services pays back the setup cost.
When to wait
Delay adoption in these cases:
- Go, Java or Python teams with thin plugin coverage. You will end up writing and maintaining the integration yourself.
- Single-project repositories. There is no graph worth modelling.
- Teams unwilling to own another versioned tool. Nx brings Node, a package manager and its own upgrade cadence.
Alternatives
You have three main options:
- Keep native tooling and use plain package-manager workspaces.
- Adopt a different monorepo build tool.
- Adopt Nx lightly by wrapping existing native commands in generic command targets. This gets you caching and ordering without committing to language-specific plugins.
create-nx-workspace vs nx init: choosing your starting point
The other path is a deliberate Nx adoption, and the first decision is whether Nx creates the repository or joins one that already exists.
create-nx-workspace |
nx init |
|
|---|---|---|
| Starting point | Empty folder | Existing repository |
| What it adds | New directory with nx.json, root package.json, preset-specific folders and plugins |
nx.json, nx as a dev dependency, cache entries in .gitignore, plugins it detects from your tooling |
| Existing code | Must be migrated in | Stays where it is |
| History | Starts fresh | Preserved |
| Trade-off | Clean conventions, but migration cost | No migration, but existing inconsistencies remain |
Use npx create-nx-workspace@latest when you are starting a new service platform and can move code in gradually. It prompts for a preset and package manager, then scaffolds the layout described in the next section. Use npx nx@latest init from the root of an existing repo when history, CI wiring and directory structure need to survive. Nx infers tasks from the scripts and config files already there, so irregular scripts become irregular targets.
Before running either command, check three things:
- Node.js: use a release line that the current Nx documentation lists as supported.
- Package manager: pick npm, pnpm or Yarn and keep using it. The lockfile Nx writes depends on that choice.
- Git: initialize a repository. Nx's affected commands compare against git history.
For nx init, commit or stash your work first. A clean working tree means the next git diff contains only what Nx changed, so you can review it, question it, or revert it in one step.
# Prerequisites: confirm the toolchain before letting Nx touch anything
node --version # check against the Nx docs' supported Node.js lines
npm --version # or pnpm/yarn: pick one and stay with it
# --- Path A: new monorepo from an empty folder ---
# Prompts for a workspace name, preset and package manager,
# then creates a new directory containing nx.json and a root package.json
npx create-nx-workspace@latest
# --- Path B: add Nx to an existing repository ---
cd ~/src/orders-platform # placeholder path to your existing repo
# Start from a clean tree so the next diff contains only Nx's changes
git status --short # should print nothing
git add -A && git commit -m "chore: snapshot before nx init"
# Adds nx.json, nx as a devDependency, cache entries in .gitignore,
# and plugins for tooling it detects
npx nx@latest init
# Review exactly what Nx changed
git status --short
git diff
# Accept the changes, or discard all of them
git add -A && git commit -m "chore: add nx"
# git reset --hard HEAD && git clean -fd # undo: back to the snapshot
Checking prerequisites, then running create-nx-workspace for a new monorepo or nx init on a freshly committed existing repo so its changes show up as one reviewable git diff.
Reading the generated workspace layout
Once you have that clean diff, you'll see a short list of files. A typical integrated workspace looks roughly like this, though your preset or options can rename or drop directories:
nx.json: workspace-level configurationpackage.jsonand a lockfile (package-lock.json,pnpm-lock.yamloryarn.lock)apps/api/withproject.jsonandsrc/
libs/(orpackages/in package-based setups)shared-utils/withproject.jsonandsrc/
.gitignore
package.json and the lockfile pin nx and every @nx/* plugin. Keep those plugin versions aligned with nx itself, because a mismatched plugin is a common source of confusing graph or executor errors.
nx.json applies to every project. A minimal one contains only a few keys:
$schema: points your editor at the schema so it can autocomplete and validate the file.defaultBase: the branch thataffectedcompares against, usuallymain.namedInputs: reusable sets of files, such as "all source except tests," that feed cache hashes.targetDefaults: shared settings per target name, for example makingbuildcacheable and depend on^build(dependencies build first).plugins: plugins that infer tasks from tool config files.
Each project can override these defaults in its own project.json, or in an nx field inside its package.json. Settings live there only when a project differs from the rest of the workspace.
Finally, check .gitignore for .nx/cache and .nx/workspace-data. The first holds local task outputs and the second holds the computed project graph. Both are machine-specific and get regenerated automatically, so committing them only creates noisy diffs and stale cache hits.
# Typical integrated workspace. Your preset or options may rename or drop directories.
my-workspace/
├── nx.json # workspace-level config: targetDefaults, namedInputs, plugins
├── package.json # pins nx and every @nx/* plugin; keep these versions aligned
├── package-lock.json # or pnpm-lock.yaml / yarn.lock, depending on package manager
├── .gitignore
├── apps/
│ └── api/
│ ├── project.json # project-level overrides; only what differs from nx.json
│ └── src/
└── libs/ # packages/ in package-based setups
└── shared-utils/
├── project.json
└── src/
# .gitignore entries for Nx's local, machine-specific data
# Local task outputs and hashes; reusing them across machines gives stale cache hits
.nx/cache
# Computed project graph and plugin data; Nx regenerates this automatically
.nx/workspace-data
An annotated tree of a typical integrated Nx workspace, plus the .gitignore entries that keep the local cache and workspace data directories out of version control.
How Nx finds projects and tasks
Nx builds its view of the workspace from two sources. The first is explicit configuration: every project.json, and every package.json in a workspace package, marks a project, and the targets defined there become runnable tasks. The second is inference. Plugins listed in the plugins array of nx.json scan for tooling config files they recognize, such as a Vite, Jest, or TypeScript config, and generate targets from them. With the right plugin, a project that contains a jest.config.ts gets a test target without anyone writing one.
Inference removes boilerplate and keeps Nx aligned with the tools' own config, so there is no second copy that can drift. The cost is visibility. When nx build api runs something unexpected, there may be no file that literally declares that target. Inspect the resolved result instead of guessing. nx show projects lists every project Nx detected. nx show project api prints the final merged configuration for one project, including inferred targets, their commands, inputs, and outputs. nx graph renders project dependencies so you can confirm Nx sees the same edges you expect.
Those edges drive task ordering through dependsOn. Setting "dependsOn": ["^build"] on build, usually once in targetDefaults, tells Nx to build a project's dependencies before the project itself. The caret means "this target on my dependencies." This addresses the complex task pipelines problem. Ordering comes from the real dependency graph, not from a hand-maintained script, so Nx can run independent builds in parallel while still building each dependency first.
# 1. List every project Nx detected, from project.json, package.json, or plugins.
npx nx show projects
# 2. Print the fully resolved configuration for one project.
# Inferred targets (e.g. a `test` target created by @nx/jest/plugin
# from jest.config.ts) appear here with their command, inputs, and outputs,
# even though no project.json declares them.
npx nx show project api
# Same data as JSON, useful for grepping where a target's command comes from.
npx nx show project api --json
# 3. Open the project graph to check that Nx sees the dependency
# edges you expect (e.g. api -> shared-utils).
npx nx graph
# 4. With this in nx.json, every build first builds its dependencies:
# {
# "targetDefaults": {
# "build": { "dependsOn": ["^build"] }
# }
# }
# The ^ means "run build on my dependencies first". Nx derives the order
# from the graph above and runs unrelated builds in parallel.
npx nx build api
# Build everything. Ordering still follows the graph, not a hand-written script.
npx nx run-many -t build
Inspect the projects and targets Nx resolves, including inferred ones, then let a ^build dependsOn rule in targetDefaults order builds by the real project graph.
Running the first build and verifying the cache
…dependents only after their dependencies finish. Now run a single build with nx build api (equivalent to nx run api:build). The output lists each task Nx executes, including dependency builds pulled in by dependsOn, followed by a summary of what succeeded.
Run the same command again without changing anything. It should finish almost instantly, and the summary should report that results were read from the cache. Nx hashes each task's inputs. When the hash matches a previous run, Nx replays the terminal output and restores the stored artifacts instead of rebuilding.
Next, edit a source file in one library and rebuild. That library and the projects that depend on it re-run. Unrelated projects still hit the cache. When you suspect the cache is hiding a problem, add --skip-nx-cache to force real execution.
What makes caching safe
The cache is only as correct as the inputs and outputs you declare.
- Missing outputs. If a build writes somewhere not listed in
outputs, a cache hit reports success but leaves that directory empty or stale. - Missing inputs. If the result depends on something Nx does not hash, Nx will replay an old result as if it were valid. Common examples are an environment variable, a config file outside the project root, or the Node version.
Declare these defaults once under targetDefaults in nx.json. Add the extra inputs with entries such as { "env": "API_BASE_URL" }, { "runtime": "node --version" }, or a {workspaceRoot}/... file path. Set outputs to {workspaceRoot}/dist/{projectRoot}. Override them per project in project.json where a target differs.
{
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
"production": ["default", "!{projectRoot}/**/*.spec.ts"]
},
"targetDefaults": {
"build": {
"cache": true,
"dependsOn": ["^build"],
"inputs": [
"production",
"^production",
{ "env": "API_BASE_URL" },
{ "runtime": "node --version" }
],
"outputs": ["{workspaceRoot}/dist/{projectRoot}"]
}
}
}
An nx.json targetDefaults entry for build. It hashes project sources, dependency sources, a shared tsconfig, an environment variable and the Node version as inputs, and declares dist/{projectRoot} as the output that Nx restores on a cache hit.
Running work across projects with run-many and affected
With caching and inputs in place, the next step is to stop running one project at a time. nx run-many -t build runs the build target for every project that defines it. Add --projects=api,worker to limit the run to a few projects. Nx still follows the task graph, so dependency builds run first, and anything already in the cache is restored instead of run again.
nx affected -t test checks fewer projects. It compares two Git commits, maps the changed files to the projects that own them, and adds every project that depends on those projects. You can set the comparison on the command line with --base=origin/main --head=HEAD, or put "defaultBase": "main" in nx.json so plain nx affected uses the right branch. In CI, base should be the commit your branch split from, or the last commit that passed on main. It shouldn't be the previous commit. Also make sure the clone has enough history to include that commit. A shallow checkout can make the comparison fail or come out wrong.
Where affected gets it wrong
affected only knows about dependencies that Nx can see. A wrong base hides changes, so projects that changed get skipped. A dependency Nx can't detect, such as a service that reads a shared SQL schema or a config file it never imports, also gets missed. Declare it with implicitDependencies in project.json, or add the file to the project's inputs. Otherwise a change there won't trigger a rebuild.
Different tool versions cause a separate problem. If Nx, Node or the package manager versions differ between laptops and CI, task graphs and build output can differ too. Pin Nx in package.json, commit the lockfile, and set the Node and package manager versions in the repository so everyone, including CI, uses the same ones.
Impact on cost and engineering time, and what to defer
Nx saves time in two ways. A repeat build with unchanged inputs is restored from the cache instead of running again. An affected run builds and tests only the projects touched by a change and the projects that depend on them.
Illustrative estimate (assumed numbers, not a benchmark). Assume 12 projects, 3 minutes to build and test each, and a typical change that affects 3 of them.
Minutes saved per run = (total projects − affected projects) × minutes per project = (12 − 3) × 3 = 27 minutes. Put in your own numbers. Local caching alone does not speed up a fresh CI runner, because the runner starts with an empty cache. On CI, the savings come from affected.
| Gain | Cost |
|---|---|
| Unchanged work served from the cache | Another tool you have to upgrade |
| Unchanged projects skipped | Inferred configuration you have to debug |
| One place to run tasks | One shared config or lockfile change can break every project at once |
Tooling does not remove that last risk. Assign owners for the root config and the lockfile, and require review on changes to them.
You can defer module boundary rules, remote caching and distributed task execution. None of them is needed for setup. A shared cache also comes with its own problems: someone has to run and secure the storage, and you have to decide who may write to it. If one machine writes a bad artifact, every other machine can download it (cache poisoning).
First-day checklist
- Workspace generated or initialized
- Project graph inspected
- One project builds
- Second run hits the cache
-
.gitignorecovers the cache directories - Changes committed
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 workspace setup, 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.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.