What the Nx project graph represents
The Nx project graph is a model Nx derives from your workspace. Each node is either a project in the repository (an application or library) or an external dependency installed from npm, such as React or Vite. Each edge says that one node depends on another. You rarely declare these edges yourself. Nx reads source imports, installed packages and TypeScript configuration and infers them, then caches the result so later runs only reanalyse changed files (Nx mental model).
Because the graph is derived rather than hand-maintained, it is only as accurate as what Nx can see in your files. Later sections cover where that breaks down.
The project graph is different from the task graph. Nx builds a task graph each time you run a command. Its nodes are project:target pairs such as myapp:build, and the project graph determines which of those nodes exist and in what order they run.
Project graph Task graph for "nx build api"
api ──► shared-db ──► pg (npm) shared-db:build
│ │
└────► shared-types ▼
│ shared-types:build ──► api:build
| Aspect | Project graph | Task graph |
|---|---|---|
| Node | Project or npm package | project:target pair |
| Edge | Code or package dependency | Task must finish first |
| When built | Continuously, cached | Per command invocation |
| Main use | Structure, affected analysis | Execution order, parallelism |
The engineers who benefit most are backend and platform engineers who own CI pipelines, build order and shared libraries. For them, a wrong edge means either a broken build or wasted CI time.
PROJECT GRAPH (derived, cached) TASK GRAPH ("nx build api")
nodes: projects + npm packages nodes: project:target pairs
edge: A ──► B = A depends on B edge: A ──► B = A runs before B
┌──────────┐ ┌───────────┐ ┌─────────────────┐
│ api │─────►│ shared-db │ │ shared-db:build │
│ (app) │ │ (lib) │ └────────┬────────┘
└────┬─────┘ └─────┬─────┘ │
│ │ ▼
│ ▼ ┌─────────────────┐
│ ┌───────────┐ │ api:build │
│ │ pg │ └─────────────────┘
│ │ (npm) │ ▲
│ └───────────┘ │
▼ ┌────────┴──────────┐
┌──────────────┐ │ shared-types:build│
│ shared-types │ └───────────────────┘
│ (lib) │
└──────────────┘
pg is an external npm node: it is in the project graph but has no task.
Project graph ──► decides which task nodes exist and the order they run.
The project graph links projects and npm packages by dependency, and Nx uses it to derive the per-command task graph, where shared-db:build and shared-types:build must finish before api:build.
Who should use the project graph
Anyone working in an Nx workspace already depends on the project graph, because every nx build, nx test and nx affected call reads it. The question is who should inspect and maintain it on purpose:
- Platform and CI owners. They decide which projects run on each pull request. Affected-only CI is only as correct as the graph's edges, so they need to know where those edges come from and where they are missing.
- Maintainers of shared libraries. A library's dependents in the graph are its blast radius. Before changing a widely imported library, check how many projects depend on it.
- Engineers debugging build order or unexpected CI runs. "Why did CI test this project?" and "Why didn't it?" are both graph questions.
nx graphandnx show projectanswer them faster than reading CI logs. - Tech leads enforcing architecture. The graph shows deep imports between services, cycles and hub libraries that code review often misses.
It matters less in a workspace with only a few tightly coupled projects. There, nearly every change affects everything, and the graph adds little beyond build ordering. It matters most when a repository has many projects and most changes touch a small part of them.
How it works, end to end
Building and using the graph happens in this order:
- Detection. Nx scans the workspace for
project.jsonfiles,package.jsonfiles included by your package manager's workspaces configuration, and files that registered plugins recognise. Each match becomes a project node. - Inference. Nx statically analyses source imports, installed packages and TypeScript path mappings, and turns each resolved import into an edge. npm packages become external nodes.
- Manual declarations. Edges you declare yourself, such as
implicitDependencies, are added for links that static analysis cannot see. - Caching. Nx stores the result and reanalyses only changed files on later runs.
- Task graph. For each command, Nx combines the project graph with each target's
dependsOnconfiguration to decide which tasks run, in what order, and which can run in parallel. - Affected and computation caching.
nx affectedwalks the graph from changed projects to their dependents. Computation caching then skips any task whose inputs haven't changed.
The next sections cover each step in detail, along with how to check it when the result looks wrong.
How Nx builds the graph: project detection, dependency inference and caching
Nx builds the graph in two passes: first it finds the nodes, then it works out the edges between them.
Project detection
Nx scans the file system for projects. A directory becomes a project if it contains a project.json file, or a package.json that your package manager's workspaces configuration includes. Plugins can change how Nx identifies projects, so a tool's own config file can mark a directory as a project. Because detection is based on files, project boundaries always match what is on disk. If a project is missing from the graph, check the workspaces globs and the plugin configuration first.
Dependency inference
Nx infers edges by analyzing three things: source code imports, installed dependencies, and TypeScript configuration such as path mappings. An import of @acme/billing resolves through a path alias or workspace package to a concrete project, and that resolution becomes an edge. This analysis is static, so it cannot see dependencies that are only expressed at runtime. Examples include a service that shells out to another project's build output, a dynamically constructed import path, or a shared contract consumed over HTTP. For these cases you declare the dependency manually, for example with implicitDependencies in project.json.
Caching the graph
Nx stores the computed graph and reanalyzes only the files that changed. Large repositories stay fast this way. The cost is a stale cache: if the graph shows an edge you deleted, or misses one you just added, the cache is a likely cause. Run nx reset to clear the cache before you investigate further.
nx show projects
nx show project billing-worker
nx reset
nx graph --file=graph.json
jq '.graph.dependencies["billing-worker"]' graph.json
A debugging sequence that checks project detection, declares a runtime-only dependency with implicitDependencies, clears a possibly stale cache with nx reset, and exports the rebuilt graph so you can inspect one project's edges.
Inspecting the graph with nx graph and nx show project
Once the cache is clean, inspect what Nx actually computed instead of assuming it matches your mental model.
nx graph opens an interactive view of every project and the project dependencies between them. Use it to answer structural questions: does api really depend on shared-utils, and why does a change in logging reach half the workspace? Each edge comes from something Nx found in source imports, installed packages, TypeScript path mappings or an explicit declaration. If an edge looks wrong, look for the import or config that produced it rather than treating the edge as correct by default.
nx show project <name> answers a different question: what tasks this project has and where each setting came from. The second part matters because Nx merges task configuration from three layers, and later sources win:
| Layer | Where it lives | Typical use |
|---|---|---|
| 1. Plugin inference | Plugins registered in nx.json read tool config such as vite.config.ts |
Default commands, inputs, outputs, cache settings |
2. targetDefaults |
nx.json |
Workspace-wide overrides for a target name |
| 3. Project config | The project's project.json or package.json |
Per-project exceptions |
Inference removes duplication, but it also means no single file describes a task. A build target's outputs might come from a plugin reading Vite config, with its inputs overridden in nx.json. Before you edit anything, use nx show project to see which layer set the value you want to change.
nx graph --focus=api
nx graph --file=graph-before.json
nx show projects
nx show project api --json
nx show project api --json | jq '.targets.build'
nx show project api --web
Shell commands for checking the project graph and then tracing a single project's merged task configuration back to the layer that set each value.
How the graph drives task execution, affected commands and caching
When you run a task, Nx builds a task graph from two inputs: the project graph and the task dependencies declared for each target. Suppose api:build has dependsOn: ["^build"]. Nx then follows the edges from api in the project graph and schedules build for every project api depends on, before api itself. Two tasks that don't depend on each other can run in parallel. The project graph says which projects are connected, and the task configuration says which of those connections affect build order. That is why the task graph comes out wrong whenever the project graph is wrong (Nx mental model).
Affected commands use the same edges. nx affected maps the changed files to the projects that own them, then walks the graph to every project that depends on those projects. Only those projects get their targets run. Computation caching then works on the resulting tasks. If a task's inputs are unchanged, Nx restores the stored output and doesn't run the task again.
This pays off most in repositories with dozens or hundreds of projects, where most changes touch only a small part of the graph. In a repo with five tightly coupled services, nearly every change affects everything, and affected-only CI skips almost nothing.
How much CI work gets skipped depends on how accurate the graph is. A missing edge means a dependent project isn't rebuilt or retested, and a broken change can merge with green checks. An extra edge means CI rebuilds projects that didn't change, which costs runner time and engineering time spent waiting on pipelines.
npx nx graph --file=project-graph.json
npx nx build api --graph=task-graph.json
npx nx show projects --affected --base=origin/main --head=HEAD
npx nx affected -t build test --base=origin/main --head=HEAD
npx nx affected -t build test --base=origin/main --head=HEAD
These commands show how Nx derives a task graph from the project graph, uses the same graph edges to pick affected projects in CI, and skips unchanged tasks through computation caching.
Reading the graph for architectural problems and their cost
The shape of the graph decides how much work CI does on every pull request, and how long engineers spend waiting on it. Two patterns cause most of that waste.
Unexpected edges and hub libraries
The first pattern is an edge that shouldn't exist, such as orders-service depending on billing-service. This usually comes from a deep import into the other service's source tree instead of through a shared library. Nx records the edge from the import, so every change to billing now makes orders affected too. To fix it, move the shared code into a library both services depend on. You can also add project tags with a boundary lint rule so the edge can't come back.
The second pattern is a hub: one shared-utils library that almost every project imports. In nx graph it appears as a single node with edges fanning out to most of the workspace. Whenever someone edits it, nearly everything is affected.
Illustrative estimate (assumed numbers, not a benchmark). Assume 60 projects, with shared-utils imported by 50 of them. Assume each affected project takes 3 runner-minutes to build and test, and that the library changes 10 times a week. Then split it into four focused libraries, each imported by about 12 projects.
Runner-minutes per week = (dependents + 1) × minutes per project × changes per week
| Layout | Affected per change | Runner-minutes per week |
|---|---|---|
One shared-utils |
51 | 51 × 3 × 10 = 1,530 |
| Four focused libraries | 13 | 13 × 3 × 10 = 390 |
Developers feel this less than the runner-minute totals suggest, because affected tasks run in parallel and cache hits skip work. Still, a smaller affected set means fewer tasks queued for each pull request and fewer unrelated test failures to sort through.
How the project graph saves engineering time
The graph saves time in four places, and each depends on the graph being accurate.
- Smaller CI runs. Affected commands run targets only for changed projects and their dependents, instead of the whole workspace. Fewer tasks per pull request means shorter queues and less waiting.
- Fewer repeated tasks. Computation caching restores outputs for tasks whose inputs haven't changed, so re-running a pipeline after a flaky failure, or rebuilding locally after switching branches, skips work that was already done.
- No hand-maintained build order. Because edges are inferred from imports, adding a new library or a new import updates build order automatically. Nobody has to edit a CI script or a dependency list to keep them in sync.
- Faster debugging. "Why did this project run?" and "Where does this target's config come from?" are answered by
nx graphandnx show project, not by reading CI scripts and several config files by hand.
Illustrative estimate (assumed numbers, not a benchmark). Assume a workspace of 40 projects, where a typical pull request changes one library with 5 dependents, and each project takes an assumed 4 runner-minutes to build and test. Running everything costs 40 × 4 = 160 runner-minutes per pull request. An affected-only run costs (1 + 5) × 4 = 24 runner-minutes. Wall-clock savings will be smaller than this ratio, because tasks run in parallel.
There is also a time cost to account for. Manual declarations need maintenance, stale caches occasionally need nx reset, and a missing edge can cost far more debugging time than it ever saved in CI. The savings are real only if someone owns the graph's accuracy.
When to avoid relying on the graph: blind spots and trade-offs
Static analysis only sees what the source code states. An import whose path is assembled at runtime, such as a plugin loader that concatenates a directory name, gives Nx no literal to resolve. Two services that talk over HTTP or a message queue share no import at all. A JSON schema or feature-flag file that several projects read from disk at startup is just a file path in a string. None of these show up as edges, so the graph understates real coupling.
The two kinds of error cost you different things:
| Failure mode | Effect | Risk type | Typical symptom |
|---|---|---|---|
| Missing edge | Affected-only CI skips a project that actually changed behaviour | Correctness | A green pull request, then a broken deploy |
| Extra edge | Unrelated projects get rebuilt and retested | Cost | Low cache hit rate and large affected sets |
Missing edges do more damage because nothing flags them. Extra edges show up in CI time, but missing edges show up later as production incidents. Manual declarations such as implicitDependencies patch the gaps, but nothing checks them against the code. When the code changes, a manual edge can become stale or just wrong, and nobody notices.
The graph also models code dependencies, not deployment coupling. Two services built from unrelated projects can still break each other through an API change. Cover those concerns separately:
- Explicit CI path filters for shared config files and other inputs the graph can't see.
- Full test suites on a schedule to catch anything affected-only runs skipped.
- Contract or integration tests between backend services that communicate at runtime.
Alternatives to an inferred project graph
An inferred graph is one way to decide what to build and test. The main alternatives trade accuracy against maintenance effort differently:
| Approach | How dependencies are known | Strengths | Weaknesses |
|---|---|---|---|
| Run everything on every change | Not tracked | Simple; nothing can be skipped by mistake | CI time grows with the whole repository, not the size of the change |
| CI path filters per directory | Hand-written globs in pipeline config | Explicit and easy to read | Transitive dependents are missed unless someone lists them; filters drift as code moves |
| Hand-maintained build scripts or dependency lists | Declared manually by engineers | Full control over order | Every new import needs a matching config edit; mistakes are silent |
| Build systems with explicitly declared per-directory build files | Each target declares its dependencies, and undeclared ones are rejected | Precise; dependencies are checked, not guessed | More configuration to write and review for every target |
| Separate repositories with versioned packages | Version ranges in package manifests | Coupling is explicit at release boundaries | No atomic cross-project changes; integration problems surface after publishing |
Nx's inferred graph sits between these. It needs less configuration than explicitly declared builds, and it tracks transitive dependents that path filters miss. But it relies on static analysis, so it inherits the blind spots described above. Many teams combine it with some of these alternatives, such as scheduled full runs and path filters for files the graph can't see.
Debugging a wrong project graph: a checklist
When an edge is missing or a project looks wrong, work through these checks in order. Each one rules out a layer before you move to the next.
-
Confirm the directory is a project. Run
nx show projectsand look for it. Nx only detects a directory through aproject.json, through apackage.jsoncovered by your package manager's workspaces configuration, or through a plugin (Nx mental model). If the directory isn't listed, no import analysis will connect it to anything. -
Trace how the import resolves. An import can resolve through a
pathsalias in the root tsconfig or through a workspace package. If the alias points somewhere unexpected, or the package name doesn't match the consumer's dependency entry, Nx attaches the edge to a different project or to none. -
Find dependencies Nx can't infer. Static analysis does not detect shared config files, generated code, file paths read at runtime or service names resolved from environment variables. Declare these with
implicitDependenciesin the project configuration. -
Check plugins. Plugins registered in
nx.jsoncan add projects, rename them, or infer targets. Runnx show project <name>to see each setting and its source. -
Reset cached state. If the graph still doesn't match the working tree, run
nx resetto clear the cached graph and stop the daemon, then regenerate the graph. If the reset fixes it, the problem was stale state. Your configuration was not the cause.
Treat manual dependencies like code. Add a short comment explaining why each one exists, and review the list when you remove or restructure a project. An implicit dependency that nobody revisits becomes a false edge. It widens affected runs and hides the real structure of the graph.
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 project graph, 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.
- The dependency graph answers "what does this touch?" in seconds instead of a manual audit.
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.
- You can size a change's blast radius before merging, avoiding surprise cross-team firefighting.
- 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.