Skip to main content

Command Palette

Search for a command to run...

Setting Up a Modern Monorepo with NX

Updated
15 min read
Setting Up a Modern Monorepo with NX

Introduction

Managing multiple related projects can quickly become overwhelming when scattered across different repositories. NX offers a powerful solution for organizing React applications, shared libraries, and backend services in a single, well-structured monorepo. This guide walks you through setting up a production-ready NX workspace with modern best practices.

Why Choose NX for Your Monorepo?

NX has evolved significantly and now offers compelling advantages for development teams:

  1. Intelligent Caching: NX automatically caches task results locally and remotely, dramatically reducing build and test times

  2. Smart Builds: Only rebuilds projects affected by your changes, saving significant CI/CD time

  3. Dependency Visualization: Interactive dependency graphs help you understand your codebase architecture

  4. Modern Tooling: Built-in support for Jest, Vitest, Cypress, Playwright, and Storybook

  5. Framework Agnostic: Supports React, Angular, Vue, Node.js, and many other frameworks

  6. Code Generation: Powerful generators create consistent, well-structured code across your workspace

  7. Nx Cloud Integration: Optional remote caching and distributed task execution for enterprise teams

Getting Started

1. Choose Your Monorepo Strategy

NX offers two approaches for organizing monorepos: Integrated and Package-Based.

Integrated Approach (Recommended for Most Teams):

  • All dependencies in root package.json

  • Single version policy across the workspace

  • Faster setup for new projects

  • Easier dependency management

  • Better for teams working on interconnected projects

Package-Based Approach:

  • More suitable if you need different dependencies and tooling between applications

  • Each project can have its own package.json with specific versions

  • Better for migrating existing projects

  • More flexibility but increased complexity

  • Ideal for independent apps with different React versions or tech stacks

2. Create Your NX Workspace

The latest version of NX (v21+) has streamlined workspace creation:

npx create-nx-workspace@latest my-workspace

During setup, you'll be prompted to choose:

  • Preset: Select react-monorepo for a React-focused workspace

  • Bundler: Choose vite (recommended) or webpack

  • Test Runner: Select vitest (modern) or jest

  • Nx Cloud: Enable for remote caching (recommended for teams)

For an integrated workspace (default), your structure will look like:

my-workspace/
├── apps/              # Applications live here
├── libs/              # Shared libraries
├── nx.json            # NX configuration
├── package.json       # Single source of truth for dependencies
└── tsconfig.base.json # Path mappings for imports

For a package-based workspace, each app/lib will have its own package.json.

2. Generate a React Application

The @nx/react plugin (formerly @nrwl/react) provides updated generators:

# Install the React plugin (if not already installed)
npm install --save-dev @nx/react

# Generate a React application
npx nx generate @nx/react:application my-app --style=scss --bundler=vite

3. Managing Multiple React Apps with Different Versions

One of the challenges in a monorepo is managing multiple apps that may need different versions of dependencies. Here's how to handle this:

Option 1: Using NPM Aliases (Integrated Approach)

If you need to support different versions of React in the same workspace, you can use NPM aliases:

// Root package.json
{
  "dependencies": {
    "react": "^19.2.0",
    "react-dom": "^19.2.0",
    "react-18": "npm:react@^18.2.0",
    "react-dom-18": "npm:react-dom@^18.2.0"
  }
}

Then configure your legacy app's tsconfig.json to map to the older version:

// apps/legacy-app/tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "react": ["../../node_modules/react-18"],
      "react-dom": ["../../node_modules/react-dom-18"]
    }
  }
}

For scenarios where apps truly need different dependency versions, use a package-based repository which is more suitable for having different dependencies and tooling between applications:

# Create a package-based workspace
npx create-nx-workspace@latest my-workspace --preset=npm

In this setup, each application has its own package.json:

my-workspace/
├── packages/
│   ├── modern-app/
│   │   ├── package.json      # React 19
│   │   └── src/
│   └── legacy-app/
│       ├── package.json      # React 18
│       └── src/
└── package.json              # Root for shared dev dependencies

Modern App (packages/modern-app/package.json):

{
  "name": "@my-workspace/modern-app",
  "version": "1.0.0",
  "dependencies": {
    "react": "^19.2.0",
    "react-dom": "^19.2.0",
    "@my-workspace/shared-ui-v3": "*"
  }
}

Legacy App (packages/legacy-app/package.json):

{
  "name": "@my-workspace/legacy-app",
  "version": "2.5.0",
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "lodash": "^4.17.0",
    "@my-workspace/shared-ui-legacy": "*"
  }
}

Option 3: Per-Project Dependency Overrides (Integrated Approach)

Even with an integrated approach, you can enable project-level dependency overrides. Create a package.json in your specific app:

// apps/legacy-app/package.json
{
  "name": "legacy-app",
  "dependencies": {
    "react": "18.2.0",
    "react-dom": "18.2.0"
  }
}

Then configure your package manager (pnpm example) to use these overrides:

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'libs/*'

Important Considerations:

  • If projects use different versions of React, runtime issues can occur when sharing components between projects

  • Maintain separate component libraries for each major version

  • Use caution when sharing code between apps with different dependency versions

  • React 19 introduced major features like Server Components and the Actions API, which may not be compatible with older React versions

3. Create Shared Libraries

Libraries enable code sharing across applications:

# Generate a UI component library
npx nx generate @nx/react:library ui-components --directory=libs/shared --style=scss --bundler=vite

# Generate a utilities library
npx nx generate @nx/react:library utils --directory=libs/shared --bundler=none

Key Options:

  • --directory: Organizes libraries into folders

  • --style: CSS/SCSS/styled-components/emotion

  • --bundler: vite, rollup, or none (for non-buildable libraries)

  • --unitTestRunner: vitest, jest, or none

Creating Version-Specific Libraries:

When managing apps with different React versions, create separate libraries for each version:

# Modern UI library (React 19)
npx nx generate @nx/react:library ui-modern --directory=libs/shared --style=scss

# Legacy UI library (React 18)
npx nx generate @nx/react:library ui-legacy --directory=libs/shared --style=scss

Configure library dependencies in package.json (package-based) or use peer dependencies:

// libs/shared/ui-modern/package.json
{
  "peerDependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

// libs/shared/ui-legacy/package.json
{
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  }
}

4. Generate Components

Create components within your libraries:

# Generate a component in a library
npx nx generate @nx/react:component Button --project=ui-components --directory=src/lib

# Generate a component with export
npx nx generate @nx/react:component Header --project=ui-components --export

The component will be generated with:

  • Component file (.tsx)

  • Style file (.scss)

  • Test file (.spec.tsx)

  • Automatic export from library's index file (if --export is used)

Working with State Management

Redux Toolkit

NX provides a Redux slice generator:

npx nx generate @nx/react:redux todos --project=my-app

This generates:

  • Redux slice with TypeScript

  • Configured store setup

  • Test files

For shared state across apps, create a dedicated state library:

npx nx generate @nx/react:library state --directory=libs/shared
npx nx generate @nx/react:redux users --project=state

Sharing Assets and Styles

Global Styles Library

Create a centralized styles library:

npx nx generate @nx/react:library styles --directory=libs/shared --style=scss --bundler=none

Configure in your app's project.json:

{
  "targets": {
    "build": {
      "options": {
        "styles": ["libs/shared/styles/src/lib/global.scss"],
        "stylePreprocessorOptions": {
          "includePaths": ["libs/shared/styles/src/lib"]
        }
      }
    }
  }
}

Then import in your components:

@import "variables";
@import "mixins";

.my-component {
  background: $primary-color;
}

Shared Assets

Configure assets in project.json:

{
  "targets": {
    "build": {
      "options": {
        "assets": [
          "apps/my-app/src/favicon.ico",
          "apps/my-app/src/assets",
          {
            "input": "libs/shared/assets/src",
            "glob": "**/*",
            "output": "assets"
          }
        ]
      }
    }
  }
}

Testing Configuration

Modern NX workspaces use Vitest by default:

# Run tests for a specific project
npx nx test my-app

# Run tests for all projects
npx nx run-many --target=test

# Run tests only for affected projects
npx nx affected --target=test

Configure coverage in vitest.config.ts:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      thresholds: {
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80
      }
    }
  }
});

E2E Testing with Cypress

Generate E2E tests with Cypress:

# Configure Cypress for an application
npx nx generate @nx/cypress:configuration --project=my-app

# Run Cypress tests
npx nx e2e my-app-e2e

# Run Cypress in interactive mode
npx nx e2e my-app-e2e --watch

Cypress tests are automatically generated in a separate e2e project:

my-workspace/
├── apps/
│   ├── my-app/
│   └── my-app-e2e/              # Cypress E2E tests
│       ├── src/
│       │   ├── e2e/
│       │   │   └── app.cy.ts
│       │   ├── fixtures/
│       │   └── support/
│       ├── cypress.config.ts
│       └── project.json

Storybook Integration

Set up Storybook for component development:

# Configure Storybook for a library
npx nx generate @nx/react:storybook-configuration ui-components

# Run Storybook
npx nx storybook ui-components

# Generate stories for all components
npx nx generate @nx/react:stories ui-components

Modern Storybook Setup (v7+):

  • Uses Vite for faster builds

  • Component Story Format 3 (CSF3)

  • Automatic story generation

  • Interaction testing support

Building and Deployment

Production Build

# Build a specific app
npx nx build my-app --configuration=production

# Build all apps
npx nx run-many --target=build --configuration=production

# Build only affected projects
npx nx affected --target=build --configuration=production

Build Optimization

Configure in project.json:

{
  "targets": {
    "build": {
      "configurations": {
        "production": {
          "optimization": true,
          "extractCss": true,
          "sourceMap": false,
          "namedChunks": false
        }
      }
    }
  }
}

Essential NX Commands

Workspace Operations

# View project dependency graph
npx nx graph

# List all projects
npx nx show projects

# Show project details
npx nx show project my-app

# Run linting
npx nx lint my-app

# Format code
npx nx format:write

Affected Commands

NX's killer feature - only work on what changed:

# Show affected projects
npx nx affected:graph

# Test affected projects
npx nx affected --target=test

# Build affected projects
npx nx affected --target=build

# Lint affected projects
npx nx affected --target=lint

Removing Projects

# Remove a project
npx nx generate @nx/workspace:remove my-old-app

# Dry run first
npx nx generate @nx/workspace:remove my-old-app --dry-run

Path Mapping and Imports

NX configures TypeScript path mappings in tsconfig.base.json:

{
  "compilerOptions": {
    "paths": {
      "@my-workspace/ui-components": ["libs/shared/ui-components/src/index.ts"],
      "@my-workspace/utils": ["libs/shared/utils/src/index.ts"],
      "@my-workspace/state": ["libs/shared/state/src/index.ts"]
    }
  }
}

Import like this:

import { Button } from '@my-workspace/ui-components';
import { formatDate } from '@my-workspace/utils';

CI/CD Integration

NX automatically generates GitHub Actions configuration:

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  main:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci

      - uses: nrwl/nx-set-shas@v4

      - run: npx nx affected --target=lint
      - run: npx nx affected --target=test
      - run: npx nx affected --target=build

Performance Best Practices

  1. Enable Nx Cloud: Provides remote caching and distributed task execution

  2. Use Affected Commands: Only build/test what changed

  3. Configure Caching: Ensure all targets are cacheable in nx.json

  4. Use Vite: Much faster than webpack for development and builds

  5. Parallel Execution: NX automatically runs tasks in parallel when possible

  6. Choose the Right Strategy:

    • Use integrated for better performance and simpler dependency management

    • Use package-based only when you truly need different dependency versions across apps

Dependency Management Strategies Comparison

NX fully supports both dependency management strategies, and you can even mix these strategies, using a single version policy for most dependencies while allowing specific projects to maintain their own versions when necessary.

Pros:

  • ✅ Simpler dependency management

  • ✅ Better caching and performance

  • ✅ Reduces duplication and conflicts

  • ✅ Easier upgrades (update once, affects all)

  • ✅ Guaranteed compatibility between shared libraries

Cons:

  • ❌ All apps must coordinate on dependency versions

  • ❌ Breaking changes affect entire workspace

  • ❌ May need to keep older apps on older framework versions

Independent Dependencies (Package-Based)

Pros:

  • ✅ Each app/package controls its own dependencies

  • ✅ Easier to migrate existing projects

  • ✅ Can run different React versions simultaneously

  • ✅ Individual apps can upgrade independently

Cons:

  • ❌ More complex dependency management

  • ❌ Potential version conflicts when sharing code

  • ❌ Larger total node_modules size

  • ❌ Harder to ensure compatibility

  • ❌ Runtime errors when sharing components between different React versions

When to Use Each Approach

Use Integrated (Single Version) When:

  • All apps are actively maintained

  • Teams can coordinate on upgrades

  • Apps share significant code

  • Performance and caching are priorities

  • Starting a new monorepo from scratch

Use Package-Based (Multiple Versions) When:

  • Migrating existing independent projects

  • Apps have vastly different technology needs

  • You need to support legacy apps alongside modern ones

  • Different teams own different apps with minimal overlap

  • Apps are published as independent NPM packages

Key Benefits Summary

  • Test Components in Isolation: Storybook integration for component development

  • Easy Code Sharing: Import libraries with clean TypeScript paths

  • Modern Tooling: Vite, Vitest, Cypress, ESLint, Prettier pre-configured

  • CLI Generators: Bootstrap apps, libraries, and components with consistent structure

  • Visual Dependency Graph: Understand project relationships at a glance

  • Smart CI/CD: Only test and build affected projects

  • Framework Flexibility: React, Node.js, and many other frameworks in one repo

  • Type Safety: Full TypeScript support across the workspace

Migration Notes from Legacy NX

If updating from older NX versions:

  1. Package Names: @nrwl/*@nx/*

  2. Commands: nx affected:testnx affected --target=test

  3. Bundlers: Consider migrating from webpack to Vite

  4. Test Runners: Consider migrating from Jest to Vitest

  5. Configuration: workspace.json deprecated, use project.json files

Run the migration command:

npx nx migrate latest
npx nx migrate --run-migrations

Final Repository Structure

After following this guide, here are two example structures depending on your approach:

Integrated Workspace Structure (Single Version Policy)

my-workspace/
├── apps/
│   ├── customer-portal/                 # Modern React 19 app
│   │   ├── src/
│   │   │   ├── app/
│   │   │   │   ├── app.tsx
│   │   │   │   ├── app.module.scss
│   │   │   │   └── redux/
│   │   │   ├── main.tsx
│   │   │   ├── assets/
│   │   │   └── styles.scss
│   │   ├── project.json
│   │   ├── vite.config.ts
│   │   └── tsconfig.json
│   │
│   ├── customer-portal-e2e/
│   │   ├── src/
│   │   │   ├── e2e/
│   │   │   │   └── app.cy.ts
│   │   │   └── support/
│   │   ├── cypress.config.ts
│   │   └── project.json
│   │
│   └── admin-dashboard/                 # Another React 19 app
│       ├── src/
│       ├── project.json
│       └── vite.config.ts
│
├── libs/
│   └── shared/
│       ├── ui-components/              # Shared React components
│       │   ├── src/
│       │   │   ├── index.ts
│       │   │   └── lib/
│       │   │       ├── button/
│       │   │       ├── header/
│       │   │       └── card/
│       │   ├── .storybook/
│       │   ├── project.json
│       │   └── vite.config.ts
│       │
│       ├── utils/                      # Shared utilities
│       │   ├── src/
│       │   │   ├── index.ts
│       │   │   └── lib/
│       │   │       ├── date-utils.ts
│       │   │       └── string-utils.ts
│       │   └── project.json
│       │
│       ├── state/                      # Shared Redux state
│       │   ├── src/
│       │   │   ├── index.ts
│       │   │   └── lib/
│       │   │       ├── store.ts
│       │   │       └── slices/
│       │   └── project.json
│       │
│       ├── styles/                     # Shared SCSS
│       │   ├── src/
│       │   │   └── lib/
│       │   │       ├── _variables.scss
│       │   │       ├── _mixins.scss
│       │   │       └── global.scss
│       │   └── project.json
│       │
│       └── assets/                     # Shared images, icons
│           └── src/
│
├── .github/
│   └── workflows/
│       └── ci.yml
│
├── node_modules/
├── dist/
├── nx.json
├── package.json                        # Single source for all dependencies
├── tsconfig.base.json
└── README.md

Package-Based Structure (Multiple Versions Support)

Best for scenarios where apps need different React versions or independent dependencies:

my-workspace/
├── packages/
│   ├── modern-app/                     # React 19 application
│   │   ├── src/
│   │   │   ├── app/
│   │   │   └── main.tsx
│   │   ├── package.json                # React 19.2.0
│   │   ├── project.json
│   │   ├── vite.config.ts
│   │   └── node_modules/               # App-specific deps
│   │
│   ├── legacy-app/                     # React 18 application
│   │   ├── src/
│   │   │   ├── app/
│   │   │   └── main.tsx
│   │   ├── package.json                # React 18.2.0, lodash 4.17.0
│   │   ├── project.json
│   │   ├── webpack.config.js
│   │   └── node_modules/
│   │
│   ├── admin-portal/                   # React 19 with different packages
│   │   ├── src/
│   │   ├── package.json                # React 19.2.0, Material-UI 6
│   │   ├── project.json
│   │   └── node_modules/
│   │
│   ├── ui-modern/                      # UI lib for React 19
│   │   ├── src/
│   │   │   ├── index.ts
│   │   │   └── lib/
│   │   │       ├── Button/
│   │   │       └── Card/
│   │   ├── package.json                # peerDeps: React 19
│   │   ├── project.json
│   │   └── node_modules/
│   │
│   ├── ui-legacy/                      # UI lib for React 18
│   │   ├── src/
│   │   │   ├── index.ts
│   │   │   └── lib/
│   │   ├── package.json                # peerDeps: React 18
│   │   ├── project.json
│   │   └── node_modules/
│   │
│   ├── shared-utils/                   # Framework-agnostic utils
│   │   ├── src/
│   │   │   ├── index.ts
│   │   │   └── lib/
│   │   ├── package.json                # No React dependency
│   │   └── project.json
│   │
│   └── shared-types/                   # TypeScript types
│       ├── src/
│       │   └── index.ts
│       ├── package.json
│       └── project.json
│
├── .github/
│   └── workflows/
│       └── ci.yml
│
├── node_modules/                       # Shared dev dependencies only
├── dist/
├── nx.json
├── package.json                        # Root-level dev dependencies
├── pnpm-workspace.yaml                 # or yarn workspaces config
├── tsconfig.base.json
└── README.md

Real-World Example: Multi-Version Dependency Configuration

Root package.json (Package-Based):

{
  "name": "my-workspace",
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  "devDependencies": {
    "@nx/react": "^21.6.4",
    "@nx/vite": "^21.6.4",
    "@nx/webpack": "^21.6.4",
    "typescript": "^5.3.0",
    "vitest": "^1.0.0",
    "eslint": "^8.55.0",
    "prettier": "^3.1.0"
  }
}

Modern App package.json:

{
  "name": "@my-workspace/modern-app",
  "version": "3.0.0",
  "dependencies": {
    "react": "^19.2.0",
    "react-dom": "^19.2.0",
    "react-router-dom": "^6.20.0",
    "@tanstack/react-query": "^5.0.0",
    "@my-workspace/ui-modern": "*",
    "@my-workspace/shared-utils": "*"
  }
}

Legacy App package.json:

{
  "name": "@my-workspace/legacy-app",
  "version": "2.14.5",
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.20.0",
    "redux": "^4.2.0",
    "lodash": "^4.17.21",
    "@my-workspace/ui-legacy": "*",
    "@my-workspace/shared-utils": "*"
  }
}

Admin Portal package.json (Different UI library):

{
  "name": "@my-workspace/admin-portal",
  "version": "1.5.2",
  "dependencies": {
    "react": "^19.2.0",
    "react-dom": "^19.2.0",
    "@mui/material": "^6.0.0",
    "@emotion/react": "^11.11.0",
    "recharts": "^2.10.0",
    "@my-workspace/shared-utils": "*"
  }
}

Key Structure Highlights

Apps Directory: Contains deployable applications. Each app has its own configuration and can be built independently.

Libs/Packages Directory: Contains reusable code. In integrated mode, organized by scope (e.g., shared). In package-based mode, each package is independent.

Project Configuration: Each project has a project.json file defining its build, test, lint, and serve targets.

Path Mappings: The tsconfig.base.json creates clean imports:

// Instead of: import { Button } from '../../../libs/shared/ui-components/src/lib/button/button'
// You write:
import { Button } from '@my-workspace/ui-components';

// Package-based with version-specific libraries:
import { Button } from '@my-workspace/ui-modern';  // For React 19 apps
import { Button } from '@my-workspace/ui-legacy';  // For React 18 apps

Dependency Graph: NX tracks dependencies between projects. For example:

  • Integrated: customer-portalui-components, state, utils

  • Package-Based:

    • modern-app (React 19) → ui-modern, shared-utils

    • legacy-app (React 18) → ui-legacy, shared-utils

    • admin-portal (React 19) → shared-utils (uses MUI instead of custom UI)

This structure scales from small teams to large enterprises, keeping your codebase organized, maintainable, and performant while supporting multiple React versions and varying dependency requirements.

Conclusion

NX has matured into a comprehensive monorepo solution that dramatically improves developer productivity. With intelligent caching, affected-only commands, and modern tooling integration, it's an excellent choice for teams managing multiple related projects. Start small with a single app and library, then scale as your needs grow.

For more information, visit the official NX documentation.

More from this blog

UI Dev

18 posts