Using Nx to Manage a Full-stack TypeScript Monorepo: Build Cache, Shared Libs, and CI/CD

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Quick Start: Create an Nx Monorepo in 5 Minutes

I spent nearly 2 weeks struggling with a codebase split across 3 separate repos — a NestJS API, a Next.js frontend, and a shared types package — before switching to Nx. After migrating, build time dropped from 8 minutes to 90 seconds thanks to caching. Here’s the fastest way to get started.

Install Nx and create a new workspace:

# Create a new workspace with the full-stack preset
npx create-nx-workspace@latest my-fullstack --preset=ts
cd my-fullstack

# Add plugins for NestJS and Next.js
npm install --save-dev @nx/nest @nx/next

Generate the backend and frontend apps right away:

# Create NestJS API
npx nx g @nx/nest:app apps/api

# Create Next.js frontend
npx nx g @nx/next:app apps/web

# Create a shared library (used by both api and web)
npx nx g @nx/js:lib libs/shared-types

The resulting directory structure:

my-fullstack/
├── apps/
│   ├── api/          # NestJS backend
│   └── web/          # Next.js frontend
├── libs/
│   └── shared-types/ # Shared types library
├── nx.json
├── package.json
└── tsconfig.base.json

Run everything immediately:

# Run both apps in parallel
npx nx run-many -t serve -p api web

# Build everything
npx nx run-many -t build --all

Understanding How Nx Works Under the Hood

Project Graph — Automatic Dependency Map

Nx automatically scans all import statements across the workspace and builds a dependency graph — no manual declarations required.

# View the dependency graph visually in the browser
npx nx graph

When apps/web imports from libs/shared-types, Nx immediately knows that if shared-types changes, web needs to be rebuilt. This graph is the foundation for Nx’s affected build feature — the biggest time-saver in CI, which I’ll cover next.

Shared Libraries in Practice

On a recent 5-person project, the bug I hated most was this: the backend defined User with createdAt: Date, while the frontend expected created_at: string — different format, different name, only caught at runtime. A shared library eliminates this problem entirely.

Create a shared interface in libs/shared-types/src/lib/user.ts:

export interface User {
  id: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
  createdAt: Date;
}

export interface ApiResponse<T> {
  data: T;
  message: string;
  success: boolean;
}

Export from libs/shared-types/src/index.ts:

export * from './lib/user';
export * from './lib/pagination';
export * from './lib/error-codes';

Import in both the backend and frontend — Nx resolves the path alias automatically:

// In the NestJS controller
import { User, ApiResponse } from '@my-fullstack/shared-types';

// In the Next.js component
import type { User } from '@my-fullstack/shared-types';

This path alias is pre-configured in tsconfig.base.json — Nx adds it automatically when generating a library, no manual edits needed.

Optimizing Build Cache — The Biggest Time Saver

Local Cache

Nx caches the results of every task based on a hash of its inputs (source files, env vars, config). Nothing changed? The task completes in milliseconds instead of minutes.

# First build: takes 3 minutes
npx nx build api
# ✓ api:build  [3m 12s]

# Second build (no changes): instant
npx nx build api
# ✓ api:build  [read from cache]  [45ms]

Configure caching in nx.json:

{
  "tasksRunnerOptions": {
    "default": {
      "runner": "nx/tasks-runners/default",
      "options": {
        "cacheableOperations": ["build", "lint", "test", "e2e"]
      }
    }
  }
}

Remote Cache with Nx Cloud

Local cache only helps on your own machine. With a team of 3 or more, you want developer A’s completed build to be available to developer B without rebuilding from scratch.

# Connect to Nx Cloud (free tier is sufficient for small teams)
npx nx connect

After running this command, Nx automatically adds the configuration to nx.json. Cache is now synced to the cloud — including CI. After enabling Nx Cloud for our team, CI dropped from 12 minutes to 3 minutes — not because CI itself got faster, but because most of the work was already cached from developers’ local machines.

Affected — Only Build What Changed

This is the feature I use most when working with PRs:

# Only test projects affected by changes since main
npx nx affected -t test --base=main --head=HEAD

# Only build affected projects
npx nx affected -t build --base=main --head=HEAD

# See which projects will be affected
npx nx affected:graph --base=main --head=HEAD

Changed a file in libs/shared-types? Nx automatically determines that both apps/api and apps/web are affected. Only changed apps/api? apps/web is left untouched — no tests, no rebuild.

CI/CD Integration

GitHub Actions with Nx Affected

The .github/workflows/ci.yml file:

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history required for nx affected to work

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci

      # Connect to Nx Cloud for remote cache
      - uses: nrwl/nx-set-shas@v4

      - name: Lint affected
        run: npx nx affected -t lint --base=${{ env.NX_BASE }} --head=${{ env.NX_HEAD }}

      - name: Test affected
        run: npx nx affected -t test --base=${{ env.NX_BASE }} --head=${{ env.NX_HEAD }}

      - name: Build affected
        run: npx nx affected -t build --base=${{ env.NX_BASE }} --head=${{ env.NX_HEAD }}

The nrwl/nx-set-shas action automatically determines the appropriate base and head SHAs — no hardcoding needed. For PRs, it compares against the base branch. For merges into main, it compares against the previous commit.

Conditional Deployment

Only deploy services that actually changed:

      - name: Deploy API if affected
        run: |
          AFFECTED=$(npx nx show projects --affected --base=${{ env.NX_BASE }} --head=${{ env.NX_HEAD }})
          if echo "$AFFECTED" | grep -q "api"; then
            echo "Deploying API..."
            # your api deploy command here
          fi

      - name: Deploy Web if affected
        run: |
          AFFECTED=$(npx nx show projects --affected --base=${{ env.NX_BASE }} --head=${{ env.NX_HEAD }})
          if echo "$AFFECTED" | grep -q "web"; then
            echo "Deploying Web..."
            # your web deploy command here
          fi

Practical Tips from Real Projects

Use Tags to Enforce Architecture Boundaries

Nx lets you tag projects and define rules about which projects can import from which. These rules prevent developers from accidentally importing database code directly into the frontend — errors surface at lint time, not during code review.

// project.json for each app/lib
{
  "tags": ["scope:api", "type:app"]
}

// libs/shared-types/project.json
{
  "tags": ["scope:shared", "type:lib"]
}

Configure the rules in .eslintrc.json:

{
  "rules": {
    "@nx/enforce-module-boundaries": [
      "error",
      {
        "depConstraints": [
          {
            "sourceTag": "scope:api",
            "onlyDependOnLibsWithTags": ["scope:shared", "scope:api"]
          },
          {
            "sourceTag": "scope:web",
            "onlyDependOnLibsWithTags": ["scope:shared", "scope:web"]
          }
        ]
      }
    ]
  }
}

Parallel Tasks with Resource Limits

# Run at most 3 tasks in parallel (prevents OOM on lower-end machines)
npx nx run-many -t build --all --parallel=3

# Run in dependency order (shared-types builds first, then api and web)
npx nx run-many -t build --all

Clear Cache When You Need a Reset

# Clear local cache
npx nx reset

# Or manually delete the cache directory
rm -rf .nx/cache

A common pitfall: you change a .env file or environment config, but Nx doesn’t track that file, so it still reads from the old cache. The build reports success but the app behaves incorrectly. Just run nx reset and try again — 2 seconds to type the command beats 20 minutes of debugging.

Generators for New Code

Instead of copy-pasting folders, use generators to create new modules that follow your team’s conventions:

# Create a new NestJS module
npx nx g @nx/nest:resource apps/api/src/users

# Create a React component
npx nx g @nx/react:component Button --project=web

# Create a new library for a specific feature
npx nx g @nx/js:lib libs/feature-auth --directory=libs/feature-auth

Beyond the dozens of built-in generators, you can write custom generators for your team’s specific patterns. I have one that scaffolds a NestJS module with unit test boilerplate — a new team member runs one command and gets exactly the right structure, no convention explanation needed.

Share: