Nx Monorepo Guide: Affected Commands, Caching and React Libraries
Master Nx monorepo setup for React: affected commands that skip untouched projects, remote caching that slashes CI times, and shared library patterns that actually scale.
Why Nx and Not Just a Plain Workspace?
Plain npm/yarn workspaces give you shared node_modules. That's it. Every build script still runs sequentially, every test suite still executes in full, and your CI pipeline gets slower every time someone adds a new package. Nx ships with a project graph, a task orchestration engine, and built-in caching — the combination is what actually makes a large monorepo manageable.
Nx has been around since 2018 but the 16.x → 17.x → 18.x release cycle (2023–2024) is where it stopped feeling like a framework opinion and started feeling like infrastructure. The plugin model matured, the nx.json configuration flattened out, and @nx/react became a proper first-class citizen rather than an afterthought. If you evaluated it a few years back and moved on, it's worth another look.
Honestly, the killer feature isn't the CLI ergonomics or the generators — it's the project graph. Nx builds a dependency graph of your entire workspace, and every other feature (affected, caching, task pipelines) is built on top of that graph. Once you understand the graph, the rest of the tool clicks.
Worth noting: you don't have to go all-in. Nx works incrementally. You can nx init inside an existing Create React App project or a Next.js repo and get caching today without restructuring everything. The big restructure can come later, or never.
Setting Up a Fresh Nx Workspace with React
Start with the create-nx-workspace CLI. Pick the react preset and you get a sensible default layout — apps/ for runnable applications, libs/ for shared code, and nx.json wired up with reasonable defaults for task runners.
npx create-nx-workspace@latest my-org --preset=react-monorepo --appName=web --bundler=vite
cd my-orgThat gives you apps/web as your first React app, already wired to Vite, with targets defined for build, test, lint, and serve. The generated project.json for apps/web will look roughly like this:
{
"name": "web",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/web/src",
"projectType": "application",
"targets": {
"build": {
"executor": "@nx/vite:build",
"outputs": ["{options.outputPath}"]
},
"test": {
"executor": "@nx/vitest:vitest"
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}One more thing — the outputs array in each target is what tells the caching engine what to store and restore. If you forget to declare outputs, Nx can't cache a target. You'll still run it, but you won't get the speedup. Get in the habit of checking that field whenever you add a custom target.
Shared React Libraries: The Right Way to Structure Them
The moment you have two apps that both need a <Button /> component, you need a shared library. Nx makes this a single command:
nx g @nx/react:library ui-components --directory=libs/ui-components --bundler=vite --component=falseYou get libs/ui-components/src/index.ts as the public API surface — everything you export from that barrel file is what other projects can import. Everything else stays private. This boundary is enforced by the @nx/enforce-module-boundaries ESLint rule, which will shout at you if apps/web tries to reach into libs/ui-components/src/internal/. It's one of the best accidental features in the whole tool.
In practice, you'll want at least three categories of shared library: UI components (pure React, no data fetching), feature libraries (connected components, hooks that call APIs), and utility libraries (date formatting, validation, constants). Keeping these separate keeps your dependency graph clean. A UI library should never import a feature library. Break that rule and you'll feel it six months later when you try to publish the UI lib to npm.
// libs/ui-components/src/index.ts
export { Button } from './lib/button/button';
export { Card } from './lib/card/card';
export type { ButtonProps } from './lib/button/button';
// apps/web/src/app/page.tsx
import { Button } from '@my-org/ui-components';That @my-org/ui-components path alias is configured automatically by the generator in tsconfig.base.json. No manual path configuration needed — which sounds small but saves real time when you're spinning up library number twelve. If you want to see how a polished component library looks before you start rolling your own, browse Empire UI components for reference — you'll get a feel for how consistent APIs and clean exports actually work at scale.
Affected Commands: Only Run What Changed
nx affected is where Nx pays for itself in CI. Instead of running tests across every project in the workspace, Nx traces which projects were touched by the current change (relative to a base commit) and runs tasks only on those projects plus any downstream dependents.
# Run tests for everything affected by changes since main
nx affected -t test --base=origin/main --head=HEAD
# Build everything affected
nx affected -t build --base=origin/main --head=HEAD
# See what would be affected without running anything
nx show projects --affected --base=origin/mainHere's the thing people miss: affected doesn't just look at file paths. It traces through the project graph. If you change libs/ui-components, every app and library that imports from it is also marked as affected, recursively. Change a deeply shared utility library and suddenly half the workspace lights up as affected — which is exactly what you'd want, because those builds and tests genuinely need to re-run.
In your CI config (GitHub Actions, GitLab CI, whatever you use), you'd typically pin --base=origin/main for PR checks and --base=HEAD~1 for main branch runs. The second case — diffing against the previous commit on main — is the "just merged, run what changed" pattern. With a 20-project workspace, this routinely cuts a 12-minute pipeline down to 90 seconds on a change that only touches one library.
Quick aside: nx affected respects the implicitDependencies field in project.json. If your app has config files (.env, nginx.conf) that aren't imported anywhere in TypeScript, list them there so changes to those files still trigger the right projects.
// apps/web/project.json
{
"implicitDependencies": [".env.production", "nginx.conf"]
}Local and Remote Caching: The Setup That Actually Matters
Local caching is on by default. Run nx build web twice — the second run restores from cache in under a second. Nx hashes your source files, your dependencies, and your executor configuration. If nothing in that hash changed, it replays the cached output instead of running the task. For build targets, that means restoring the entire dist/ folder from disk.
Remote caching is where teams get the real wins. Instead of each CI agent recomputing outputs from scratch, they all share a remote cache. Developer A's morning build gets cached. When CI runs an hour later on the same commit, it fetches from the cache and completes in seconds. Nx Cloud is the official remote cache solution (previously free for small teams, check current pricing tiers if you're evaluating it for a larger org), but there are open-source options too.
# Enable Nx Cloud (interactive setup)
nx connect
# Or set the runner manually in nx.json// nx.json
{
"tasksRunnerOptions": {
"default": {
"runner": "nx-cloud",
"options": {
"cacheableOperations": ["build", "test", "lint", "e2e"],
"accessToken": "your-nx-cloud-token"
}
}
}
}Look, you can also self-host a remote cache with nx-remotecache-s3 or nx-remotecache-azure — community packages that plug into the same runner interface. They're not as polished as Nx Cloud's DTE (Distributed Task Execution) feature, but for straightforward "share build artifacts between CI agents" use cases, they work fine. The S3 one in particular takes about 30 minutes to configure if you already have an AWS account.
Task Pipelines and Parallel Execution
By default Nx runs tasks in parallel with up to three concurrent processes. You can crank that up with --parallel=8 or tune the default in nx.json. But the more important config is the targetDefaults.dependsOn field — this is how you express that build for an app needs to run build on all its library dependencies first.
// nx.json
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/dist"]
},
"test": {
"dependsOn": ["build"]
},
"lint": {
"outputs": ["{projectRoot}/.eslintcache"]
}
}
}The ^build syntax means "build all projects that this project depends on, first". The caret is easy to miss when you're new to Nx. Without it, you'd write "dependsOn": ["build"] which means "run my own build target first" — useful for test depending on build within the same project.
Nx 18 introduced "continuous" task mode for long-running tasks like serve, which lets dev servers restart in the correct order when you change a library. It's still somewhat rough around the edges as of mid-2026, but worth experimenting with if your team regularly runs multiple apps simultaneously.
That said, the gains from getting dependsOn right are immediate and real. Before tuning this, teams often see race conditions where apps try to import library types that haven't been compiled yet. Set it up once, and those races go away.
Integrating Design System Libraries with Nx
A design system library in an Nx monorepo follows the same pattern as any other lib, but with extra considerations: you want Storybook co-located, you probably want to publish to npm at some point, and you need your components to stay visually consistent across apps. The @nx/storybook plugin handles the co-location part cleanly — it generates a .storybook/ folder inside the library directory rather than at the root.
# Add Storybook to an existing UI library
nx g @nx/storybook:configuration ui-components --uiFramework=@storybook/react-vite
# Run Storybook for just that lib
nx storybook ui-componentsFor publishing, Nx 18's @nx/js:release target handles version bumping and changelog generation across multiple publishable libraries in one command. It's not perfect — the changelog format is opinionated and you'll likely customise it — but it beats managing independent package.json versions by hand.
Honestly, the place most teams get tripped up is CSS. Shared component libraries need their styles to be available in the consuming app without requiring consumers to import CSS files manually. The cleanest pattern is to bundle CSS-in-JS (Emotion, Tailwind v4's CSS layer approach) so the styles ship with the JS. If you're going the Tailwind route, make sure your app's tailwind.config.js includes the lib's source in the content array — otherwise Tailwind will purge the classes your library uses. You can see this problem clearly if you've ever tried to wire up a component library like Empire UI into a fresh Tailwind project and noticed half the styles disappear on production builds.
One workflow that pays dividends: keep a gradient generator or box shadow generator bookmarked while you're building out your shared design tokens. It's much faster to prototype a token value visually and then paste the CSS value into your style-tokens.json than to iterate blind in code.
FAQ
nx run-many runs a target on an explicit list of projects (or all of them with --all). nx affected figures out which projects were changed relative to a git base commit and runs only those plus any dependents. In CI, affected is almost always what you want — run-many --all is for when you genuinely need a full rebuild.
Yes, but you have to declare them. Add env vars that affect your build output to the inputs array in targetDefaults. Nx will include them in the cache hash. If you don't declare them, Nx won't know a change in NODE_ENV from development to production should bust the cache.
Yes. Run npx nx@latest init in your project root and Nx will detect your existing scripts and wrap them with caching. You don't need to move anything. The full apps/ and libs/ structure is optional — you can adopt it incrementally as you extract shared libraries.
Use the @nx/enforce-module-boundaries ESLint rule and assign tags to your projects in project.json (e.g. "tags": ["scope:ui", "type:lib"]). Then write rules in nx.json that say type:feature can depend on type:ui but not the other way around. It's opt-in but well worth setting up early.