What Nx is (and is not) in a React monorepo

Nx is a build system and task orchestrator for JavaScript and TypeScript monorepos. It does not compile your React code, render components or replace your bundler. Its job is to understand how the projects in a repository relate to each other, and to use that information to decide what to run, in what order, and what it can skip.

It works in three steps. First, Nx builds a project graph by reading each project's configuration and analysing the import statements in your source files. If apps/storefront imports from libs/ui-button, that becomes an edge. Second, it derives a task graph from the projects and their targets. A build on an app depends on build for the libraries it consumes. Third, it runs those tasks in dependency order, parallelises independent branches, and caches each result. The cache key is computed from the task's inputs, so a task whose inputs have not changed is replayed rather than re-executed (Nx introduction).

React tooling stays in place. Nx plugins wrap tools you already know, such as Vite or Webpack for bundling, Jest or Vitest for tests, and ESLint for linting. They expose these tools as targets the task graph can schedule.

For backend and platform engineers, the useful mental model is a dependency graph whose edges you can enforce, not a folder of packages. Once the edges are explicit, CI can test only what a change affects, and lint rules can reject imports that break your layering. Nx pays off most for teams running several React apps that share UI and domain logic, and for the platform engineers who own the CI bill.

Workspace layout: thin apps, typed libraries

The layout that scales treats every app as a thin shell. An app owns routing, top-level composition, environment configuration and bootstrapping, and little else. Most code lives in libraries, each with a single type. This matters for Nx: the smaller the app, the more of the codebase sits in projects that can be cached, tested and marked affected on their own.

 apps/storefront      apps/admin
        \               /
         v             v
   feature-checkout  feature-orders
      |      \        /     |
      v       v      v      v
   ui-*     data-access-*   |
      \          |          |
       v         v          v
            util-*
Type Contains May import
feature Smart components, route-level screens, use-case orchestration feature, ui, data-access, util
ui Presentational components driven only by props ui, util
data-access API clients, state, caching, server-state hooks data-access, util
util Pure functions, types, formatters util

Dependencies flow downward only. A ui component that fetches data, or a util that imports React state, is a layering bug.

Apps and libraries import each other through workspace aliases such as @acme/orders/feature, never through relative paths like ../../libs/.... Depending on the workspace setup, those aliases are TypeScript path mappings in tsconfig.base.json or workspace package names. Relative paths across project folders hide the dependency from the project graph and bypass boundary rules.

Set library granularity by policy. A reasonable rule is one library per domain per type, split further only when part of it has a different owner, change rate or set of consumers. Very small libraries add configuration and cognitive load. Very large ones mean every edit marks most of the repo affected and invalidates the cache.

+-------------------+                       +-------------------+
|  apps/storefront  |                       |    apps/admin     |
|  routing, config, |                       |  routing, config, |
|  bootstrap        |                       |  bootstrap        |
+---------+---------+                       +---------+---------+
          |      imports via @acme/* aliases          |
          v                                           v
+-------------------+                       +-------------------+
| feature-checkout  |                       |  feature-orders   |
| screens, use cases|                       | screens, use cases|
+---------+---------+                       +---------+---------+
          |                                           |
          +---------------------+---------------------+
                                |
                 +--------------+--------------+
                 v                             v
       +-------------------+         +-------------------+
       |       ui-*        |         |   data-access-*   |
       | props-only views  |         | API, state, cache |
       +---------+---------+         +---------+---------+
                 |                             |
                 +--------------+--------------+
                                v
                      +-------------------+
                      |      util-*       |
                      | pure fns, types   |
                      +-------------------+

  Arrows point from importer to dependency. Nothing imports upward.

Thin apps import feature libraries through workspace aliases, and dependencies flow downward through ui and data-access libraries to util, never back up.

Scaffolding apps, libraries and components with Nx generators

Once you have decided where the boundaries go, let the React plugin create the projects rather than copying config from an existing one. The plugin's generators (@nx/react:application, @nx/react:library and @nx/react:component) write the project configuration, TypeScript path mappings, lint setup and test scaffolding in the layout the rest of the plugin expects. Hand-written projects usually miss one of these, and you find out later when a target or an import alias behaves differently from its siblings.

Before generating anything, check what the installed plugin supports. nx list @nx/react shows its generators, and adding --help to a generator command prints its options and defaults. Generator names and flags change between major versions, so check them against the plugin version in your lockfile rather than against a blog post, this one included. Then run the generator with --dry-run. It prints the files it would create or modify and writes nothing. This lets you confirm the directory, the project name and any edits to shared files such as the root TypeScript config before they show up in a diff.

The trade-off is that generators encode the plugin's conventions at a particular point in time. nx migrate and the plugin's automated migrations assume config that looks roughly like what the generator produced. The more you rewrite generated config by hand, the more often those migrations fail or skip your projects. Each upgrade then turns into a manual reconciliation. Customize where you have a reason to, and keep a note of what you changed and why.

# 1. See which generators the installed @nx/react plugin provides.
npx nx list @nx/react

# 2. Print each generator's options and defaults for *your* installed version.
#    Names and flags change between majors, so check these before copying
#    commands from any article, including this one.
npx nx g @nx/react:application --help
npx nx g @nx/react:library --help
npx nx g @nx/react:component --help

# 3. Preview with --dry-run. Nx prints the files it would CREATE or UPDATE
#    and writes nothing. Look at the project name, the directory and any
#    edits to shared root files such as tsconfig.base.json.
#    (Recent Nx versions take the target directory as the positional
#    argument. Older versions may expect a name plus --directory.
#    Confirm with --help.)
npx nx g @nx/react:application apps/storefront --dry-run
npx nx g @nx/react:library libs/shared/ui --dry-run
npx nx g @nx/react:component libs/shared/ui/src/lib/button/button --dry-run

# 4. When the preview looks right, run the same command without --dry-run.
npx nx g @nx/react:library libs/shared/ui

# 5. Commit the generated output before you customize it. Your later edits
#    then show up as a separate diff, which you can check again when
#    `nx migrate` runs during upgrades.
git add -A && git commit -m "chore: generate shared-ui library with @nx/react"

Lists the @nx/react generators, inspects their options with --help, previews an app, a library and a component with --dry-run, then generates the library for real and commits the untouched output so later customizations stay visible during upgrades.

Building a governed shared UI library

The shared UI library is the first one most teams create, and the first one to drift. Generate it with the React library generator under a path such as libs/shared/ui. The main decision is whether it should be buildable.

Aspect Buildable library Non-buildable library
Compilation Has its own build target and output Compiled from source by each consuming app
Incremental builds Cached output can be reused across apps No separate artifact, so nothing is reused
Configuration Per-library build config and output paths to maintain Path alias plus lint and test targets
Good default for Large libraries, or code you also publish Most internal UI libraries

Start non-buildable. Switch only when you have measured that app builds are slow because of repeated compilation. Until then, a buildable library adds config without saving time.

Treat the library's index.ts as its public API. Export the components, their prop types, and the theme provider, and nothing else. Consumers import through the workspace path alias, never through deep paths. This lets you refactor internal files freely, and it shows reviewers exactly what changed when an export is added.

Keep styling inside the library: design tokens, the theme, and component styles. Apps should not need to know how a button gets its colour. Keep app-specific state, routing, and data fetching out of it. A component that calls an API is a feature, and it belongs in a feature library.

Every app depends on this library, so a breaking prop change can break all of them in one commit. Change its API deliberately. Deprecate an export before removing it, and run affected tests across all consumers before you merge.

// libs/shared/ui/src/index.ts
// Public API of the shared UI library. Anything not exported here is internal
// and can be refactored without notifying consumers.

// Theme: tokens and provider live in the library so apps never style primitives directly.
export { ThemeProvider } from './lib/theme/theme-provider';
export type { Theme, ThemeProviderProps } from './lib/theme/theme-provider';

// Components and their prop types, exported together so consumers can wrap them safely.
export { Button } from './lib/button/button';
export type { ButtonProps } from './lib/button/button';

export { TextField } from './lib/text-field/text-field';
export type { TextFieldProps } from './lib/text-field/text-field';

export { Modal } from './lib/modal/modal';
export type { ModalProps } from './lib/modal/modal';

// Deprecated: kept for one release cycle so every consuming app can migrate first.
// Remove only after affected tests pass across all apps without this export.
/** @deprecated Use `Button` with `variant="link"` instead. */
export { LinkButton } from './lib/link-button/link-button';

// Not exported on purpose:
// - internal helpers (./lib/utils/*)
// - raw style files and token maps (consumed via ThemeProvider)
// - anything that fetches data or reads app state (belongs in a feature library)

// Consumers import through the workspace path alias, never via deep paths:
//   import { Button, ThemeProvider } from '@acme/shared-ui';      // correct
//   import { Button } from '@acme/shared-ui/src/lib/button/button'; // avoid

An explicit index.ts entry file that defines the shared UI library's public API, keeps internals and data concerns out, and deprecates an export before removing it.

Enforcing module boundaries with tags and lint

Boundaries work only if every project carries labels the lint rule can check. Give each project two tags in its project.json tags array. The first is a type, such as type:app, type:feature, type:ui, type:data-access or type:util. The second is a scope, such as scope:billing, scope:admin or scope:shared. The type describes what a project is allowed to do. The scope describes which part of the business it belongs to.

The @nx/enforce-module-boundaries ESLint rule reads these tags through its depConstraints option. Each constraint pairs a sourceTag with onlyDependOnLibsWithTags. For example, type:ui may depend only on type:ui and type:util. If a button component imports a data-access hook, lint fails. This keeps the shared UI library presentational and safe to reuse anywhere. type:app may depend on feature, ui, data-access and util libraries but not on other apps. The rule already rejects imports from application projects, and the constraint states that intent explicitly. Scope constraints work the same way: scope:billing may depend only on scope:billing and scope:shared.

These rules stop architectural erosion because a bad import fails when it is written, not months later during a refactor. They also hold when the people who designed the layering are no longer reviewing pull requests.

The rules depend on two conditions. Tags must stay accurate. A new library with no tags, or one copied with the wrong tags, is checked against the wrong constraints or none at all. The lint target must also run in CI as a required check. If lint runs only in editors, the constraints become stale documentation.

// eslint.config.mjs (workspace root)
// Each project.json carries one type tag and one scope tag, e.g.
//   "tags": ["type:ui", "scope:shared"]
import nx from '@nx/eslint-plugin';

export default [
  ...nx.configs['flat/base'],
  ...nx.configs['flat/typescript'],
  ...nx.configs['flat/javascript'],
  {
    files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
    rules: {
      '@nx/enforce-module-boundaries': ['error', {
        allow: [],
        depConstraints: [
          // Type rules: what a project may depend on.
          // type:app is absent from every allow-list, so no project can import an app.
          { sourceTag: 'type:app', onlyDependOnLibsWithTags: ['type:feature', 'type:ui', 'type:data-access', 'type:util'] },
          { sourceTag: 'type:feature', onlyDependOnLibsWithTags: ['type:feature', 'type:ui', 'type:data-access', 'type:util'] },
          // UI stays presentational: no feature or data-access imports.
          { sourceTag: 'type:ui', onlyDependOnLibsWithTags: ['type:ui', 'type:util'] },
          { sourceTag: 'type:data-access', onlyDependOnLibsWithTags: ['type:data-access', 'type:util'] },
          { sourceTag: 'type:util', onlyDependOnLibsWithTags: ['type:util'] },

          // Scope rules: a business area may use its own code plus shared code.
          { sourceTag: 'scope:billing', onlyDependOnLibsWithTags: ['scope:billing', 'scope:shared'] },
          { sourceTag: 'scope:admin', onlyDependOnLibsWithTags: ['scope:admin', 'scope:shared'] },
          { sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
        ],
      }],
    },
  },
];

// In CI, make this a required check so violations block merges:
//   npx nx affected -t lint

A root ESLint flat config that uses project tags to keep ui libraries from importing feature or data-access code, keeps apps out of every allow-list, and limits each business scope to its own code plus shared code.

Project graph, affected commands and caching in CI

Before merging a change to the shared UI library, run nx graph and select that library. The graph shows every app and library that imports it, directly or transitively, which tells you how much of the workspace you are about to touch.

In CI, nx affected -t lint test build runs those targets only for projects changed between a base and a head commit, plus everything that depends on them. Pass explicit --base and --head SHAs, usually the merge base with the main branch and the PR's head commit. Nx computes the change set from git history. A shallow clone can't locate the merge base, so the result may be wrong or Nx may fall back to running far more work than needed. Fetch enough history.

A task is cacheable when its inputs are deterministic and its outputs are declared. Nx hashes the source files, configuration and dependencies. On a matching hash it replays the stored output and skips the run. A remote cache shares those results across CI runs and developer machines. Any input you don't declare, such as an environment variable, a generated file or a tool version, is left out of the hash. When that input changes, the hash doesn't, and Nx replays a stale success that looks like a passing build.

Illustrative estimate

Assumed inputs: three apps and eight libraries. The shared UI library is used by four feature libraries and all three apps.

Change Projects affected Tasks (lint, test, build)
Shared button component 8 of 11 24
One app's feature library 2 of 11 6

The shared component change affects four times as many tasks as the feature library change, which is why it needs the wider review.

When to use it, when to avoid it, alternatives and cost

That asymmetry drives the cost picture. Design-system work consists mostly of shared-UI changes, and those get the smallest CI savings, because affected analysis correctly pulls in nearly every consumer. Caching helps on reruns, but the first pipeline after a button change still runs most of the workspace.

Illustrative estimate (assumed numbers, not a benchmark): net weekly hours saved = PRs × (full-run min − mean affected-run min) ÷ 60 − maintenance hours. With 50 PRs, a 30-minute full run, a 12-minute mean affected run and 4 hours of upkeep: 50 × 18 ÷ 60 − 4 = 11 hours. Upkeep covers config, boundary tags, and Nx and plugin upgrades. A UI-heavy PR mix pushes the mean affected time toward the full run, and the savings shrink with it.

Nx workspaces usually keep one root dependency manifest. Under that single-version policy, a React or UI-library upgrade touches every app in the same change. The benefit is that nothing drifts. The drawback is that no app can stay on an older version.

Approach Shared code Independent releases CI scoping Tooling ownership
Nx monorepo Direct imports, enforced boundaries Hard Affected + cache Required
Polyrepos + published packages Versioned packages Natural Per repo Low, but release overhead
Plain package-manager workspace Direct imports Possible Manual or none Minimal
Other monorepo build tools Varies Varies Usually graph-based Required

Nx pays off when you have several apps, code that is genuinely shared, and CI time that is a real bottleneck. Avoid it for a single small app, for teams that need independent release and dependency schedules, or when no one will own the tooling.

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 react, 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 .env by 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.