Note
I finally tried Nx. Here's what stuck.
Monorepo Tooling TypeScript NX 10 min read Updated May 2026
For a few years I'd brushed past Nx and mentally filed it under 'monorepo tooling, maybe one day.' I sat with it for a weekend. Two things stuck: CI that scales with the change rather than the repo, and architecture that doesn't quietly rot through a thousand small imports.
For a few years I’d been brushing past Nx. Every time it came up in a thread or a coworker’s slide, I’d nod, mentally file it under “monorepo tooling, maybe one day,” and forget about it. Last week I sat with it for a weekend.
Two things stuck. The rest of Nx is plumbing in service of those two ideas, and once you see them, the broader feature list reads as consequences rather than as a marketing checklist.
This is what I’d want to read if I were a month behind where I am now.
The first thing: CI that scales with your change, not your repo
The pain point Nx solves first is one most monorepos eventually run into. You have, let’s say, twenty libraries and a handful of apps. You change one line in one component. CI starts up, and:
It re-typechecks everything, because TypeScript has no way of knowing which downstream projects actually consumed the file you changed.
It re-runs every test in every package, because the root test script almost certainly does jest or vitest from the top.
It re-lints everything. It rebuilds every bundle.
In a small repo this is fine. At fifty-plus packages, PRs take fifteen-to-thirty minutes apiece. People stop running tests locally because “CI will catch it.” Builds pile up behind each other. The repo becomes a friction surface that no single change is responsible for, but everyone pays for every day.
What’s frustrating about that situation is the work is mostly wasted. Your change touched one library. Every other project’s build outputs and test results are byte-for-byte identical to what they were ten minutes ago. You’re regenerating them because your toolchain can’t tell which projects were affected by your change.
Nx’s answer is not new. Bazel and Buck have done versions of it for years. What Nx gets right is the weight: the same idea, but light enough to adopt in an afternoon without rewriting your project in a new build language.
When you ask Nx to run a target — nx test myapp, say — it does roughly this:
It builds a content hash of the project’s source files plus its transitive dependencies plus the command being run.
It checks .nx/cache/ for that hash. If it’s there, the previous output is replayed. Terminal output, file outputs, all of it. Takes milliseconds.
If not, the command runs for real, and the result is cached under that hash.
Then affected adds the second half: compare your branch against main (or any other ref) and figure out which projects’ inputs have changed. Run targets against only those. Change one file in a shared util on a feature branch; nx affected -t test runs tests in that util and in everything that transitively depends on it. Nothing else.
In CI you point --base at the previously deployed commit, and PR work shrinks to the actual blast radius of the PR.
The remote cache is where this starts feeling unfair. Turn on Nx Cloud or a self-hosted alternative and the cache key is shared across the whole team. The first developer to touch a project pays the build cost; everyone after, including CI, replays from the shared cache until inputs change. CI stops scaling with repo size and starts scaling with what the PR actually touches.
The mental shift took me a few hours to articulate. Traditional CI thinks of itself as a fresh build, full stop. Repeatability via the brute force of doing everything from scratch. Nx treats CI as a cache-aware function from repo state to outputs. Repeatability is preserved because the cache key includes every input that could affect the result. You’re not skipping work; you’re memoizing it.
Once I held that thought, the rest of the design fell into place.
The second thing: architecture that doesn’t melt
The other feature is undersold in the Nx marketing. There’s no glossy landing page for it. But it’s the piece I’d find hardest to give up after a year of using Nx, and I think most teams underestimate how much they need it until they’ve felt the alternative.
The problem is the one every monorepo eventually has. You whiteboard your architecture in the first month: front-end apps call services, services share a utilities layer, the auth module shouldn’t depend on the billing module, domain logic shouldn’t touch the database directly. You write it in a CONTRIBUTING.md, mention it during onboarding, and feel responsible.
Twelve months later, someone is up against a deadline at 9pm and needs a function that happens to live in another module. They write the import. The reviewer doesn’t catch it because the diff looks innocuous. Six months after that, you can’t move the billing module without breaking auth.
Architecture rots through a thousand small imports, none of them outrageous in isolation.
Nx’s response is to make every project carry tags — free-form labels like scope:billing or type:domain — and to ship an ESLint rule that reads those tags and the import graph, and fails the build when a forbidden cross happens.
A small piece of the config:
{
"@nx/enforce-module-boundaries": ["error", {
"depConstraints": [
{ "sourceTag": "type:app", "onlyDependOnLibsWithTags": ["type:feature", "type:shared"] },
{ "sourceTag": "type:feature", "onlyDependOnLibsWithTags": ["type:feature", "type:shared", "type:domain"] },
{ "sourceTag": "type:domain", "onlyDependOnLibsWithTags": ["type:domain", "type:shared"] }
]
}]
}
That’s the rule for the type dimension: layers go in one direction. Domain code can’t reach into app code. Apps can’t reach into domain code without going through a feature. The same idea on a second scope: axis keeps product areas from fusing — billing can talk to shared, auth can talk to shared, billing and auth never touch each other.
The 9pm shortcut from earlier? Lint fails. Build fails. PR stops. The architecture doesn’t depend on the reviewer noticing; it depends on a rule that doesn’t get tired.
What I like isn’t the rule itself. Many tools have something equivalent. What I like is where Nx puts the boundary. The library is the unit. The index.ts barrel is the public API. Deep imports from another library are forbidden by default. So even if you mark a helper as export in some internal file, it doesn’t escape the library unless you also re-export it from the barrel. The library’s exposed surface is what you actually meant to expose, and the build verifies it.
This single property eliminates the “we exported it once for one consumer, and now half the codebase depends on its internals” failure mode that I’ve watched destroy more than one codebase.
The smaller features, briefly
Once those two ideas are in place, the rest of Nx is useful plumbing.
nx g @nx/js:library mything scaffolds a new lib with all the boilerplate. The win isn’t the typing saved. It’s that every new lib in your repo looks like every other new lib, so new engineers stop copying from whichever neighbor they happened to glance at.
nx graph opens a browser tab with an interactive dependency graph. The first time you look at your codebase this way, you’ll see at least one connection you didn’t know existed.
nx run-many -t build does the obvious thing in parallel, with cache awareness. It knows lib-b depends on lib-a and won’t try to build them at the same time.
The plugin ecosystem covers React, Next, Nuxt, NestJS, Express, Vite, Webpack, Storybook, Cypress, Jest, Vitest. Each plugin knows how to scaffold and what inputs to track for caching. It’s what lets Nx be “all things to all stacks” without becoming “nothing to anyone.”
When Nx is overkill
I’ll be direct about this, because it’s the question I’d want answered before adopting it.
A single Next.js app with no shared libraries doesn’t need Nx. Use the framework’s tooling and don’t add complexity for its own sake.
A two-week prototype that might be scrapped doesn’t need Nx. The setup cost is real and you won’t be around to enjoy the payoff.
A team that doesn’t run lint as a CI gate gets nothing from the architecture-enforcement piece. The rule has to actually run.
If you’ve adopted Turborepo and are happy, the caching/affected story is largely the same. Turbo is lighter and doesn’t try to do the architecture-enforcement piece. If you don’t need that piece, the case for switching is weak.
Where Nx wins: monorepos with multiple apps, multiple bounded contexts, a team big enough that “architecture review on every PR” stops being realistic, and a CI runtime that’s started to hurt. That’s the cluster of conditions where the structure pays back.
If you want to try it
The path I’d recommend, which took me about an hour:
npx create-nx-workspace@latest my-org --preset=tsand poke around what gets generated.- Generate two libraries that depend on each other.
- Run
nx graphto see them in the visualizer. - Add a tag rule forbidding one from depending on the other. Try the import. Watch the build refuse it.
- Change a file and run
nx affected -t test --base=HEAD~1. Watch it skip the unaffected projects.
By the end you’ve seen both things I claimed mattered. Everything else in Nx is a consequence of those.
To close
I went in expecting another monorepo tool to evaluate. I came out with two ideas I now think every multi-app TypeScript codebase should adopt eventually, and one tool that makes them both available without forcing me to learn a new build language.
CI that scales with the PR, not the repo. Architecture verified by the build, not by humans. Those two changes are worth a weekend of your attention.