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.
