Introduction
When your product grows from a single app into multiple related projects — a customer-facing website, an admin panel, a shared component library — keeping code in sync across separate repos becomes painful. You copy-paste components, maintain duplicate logic, and dread the moment a shared utility needs updating in five places at once.
Nx is a smart build system built for this exact problem. It turns multiple projects into a single coherent workspace where:
- Shared libraries are imported like any npm package (
@myorg/ui) - Only code affected by your changes gets rebuilt or retested
- Generators enforce consistent scaffolding across teams
- Remote caching (via Nx Cloud) means CI runs in seconds, not minutes
This guide walks you through building a production-ready Nx workspace with two Next.js applications sharing a UI component library and a utilities library.
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed (
node --version) - pnpm 8+ installed (
npm install -g pnpm) - Basic knowledge of TypeScript and React/Next.js
- Git configured on your machine
What You'll Build
By the end of this tutorial, your workspace will contain:
apps/web— Main Next.js 15 applicationapps/admin— Admin Next.js 15 applicationlibs/ui— Shared React component library (@myorg/ui)libs/utils— Shared TypeScript utilities (@myorg/utils)- CI configuration using
nx affectedto skip unchanged projects
Step 1: Create the Nx Workspace
Use the official scaffolding command to bootstrap an empty workspace:
npx create-nx-workspace@latest myorg --preset=empty --pm=pnpm
cd myorgThe --preset=empty flag creates a minimal workspace — no apps yet, just the Nx configuration. You'll see:
myorg/
├── nx.json # Nx configuration and caching rules
├── package.json # Root dependencies
├── pnpm-workspace.yaml # pnpm workspace config
└── tsconfig.base.json # Shared TypeScript paths
Install the plugins you'll need:
pnpm add -D @nx/next @nx/react @nx/jsStep 2: Add Two Next.js Applications
Generate the main web application:
npx nx generate @nx/next:app web \
--directory=apps/web \
--style=css \
--appRouter=trueGenerate the admin application:
npx nx generate @nx/next:app admin \
--directory=apps/admin \
--style=css \
--appRouter=trueVerify both work:
npx nx serve web # runs on http://localhost:3000
npx nx serve admin # runs on http://localhost:4200Each app is fully independent — separate next.config.js, separate app/ router, separate port.
Step 3: Create a Shared UI Library
Generate a React component library that both apps can import:
npx nx generate @nx/react:library ui \
--directory=libs/ui \
--unitTestRunner=vitest \
--bundler=vite \
--importPath=@myorg/uiThe --importPath sets the TypeScript alias. Any app in the workspace can now write import { Button } from '@myorg/ui' and TypeScript resolves it automatically.
Create a reusable Button component:
// libs/ui/src/lib/button/button.tsx
import React from 'react';
export interface ButtonProps {
children: React.ReactNode;
variant?: 'primary' | 'secondary' | 'danger';
onClick?: () => void;
disabled?: boolean;
className?: string;
}
export function Button({
children,
variant = 'primary',
onClick,
disabled,
className = '',
}: ButtonProps) {
const base = 'px-4 py-2 rounded font-medium transition-colors focus:outline-none focus:ring-2';
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-100 text-gray-800 hover:bg-gray-200 focus:ring-gray-400',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
};
return (
<button
className={`${base} ${variants[variant]} ${className}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}Create a Card component:
// libs/ui/src/lib/card/card.tsx
import React from 'react';
export interface CardProps {
title: string;
description?: string;
children?: React.ReactNode;
className?: string;
}
export function Card({ title, description, children, className = '' }: CardProps) {
return (
<div className={`rounded-lg border border-gray-200 bg-white p-6 shadow-sm ${className}`}>
<h3 className="mb-1 text-lg font-semibold text-gray-900">{title}</h3>
{description && <p className="mb-4 text-sm text-gray-600">{description}</p>}
{children}
</div>
);
}Export everything from the library entry point:
// libs/ui/src/index.ts
export * from './lib/button/button';
export * from './lib/card/card';Step 4: Create a Shared Utilities Library
Generate a plain TypeScript library (no React dependency) for business logic:
npx nx generate @nx/js:library utils \
--directory=libs/utils \
--unitTestRunner=vitest \
--bundler=tsc \
--importPath=@myorg/utilsAdd formatting helpers:
// libs/utils/src/lib/format.ts
export function formatDate(date: Date, locale = 'en-US'): string {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date);
}
export function formatCurrency(
amount: number,
currency = 'USD',
locale = 'en-US'
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(amount);
}
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength).trimEnd() + '…';
}Add shared TypeScript types:
// libs/utils/src/lib/types.ts
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
}
export interface ApiError {
code: string;
message: string;
details?: Record<string, string[]>;
}
export interface SelectOption<T = string> {
label: string;
value: T;
disabled?: boolean;
}Export from the library entry:
// libs/utils/src/index.ts
export * from './lib/format';
export * from './lib/types';Step 5: Use Shared Libraries in Your Apps
Import the shared libraries in your Next.js apps exactly as you would npm packages:
// apps/web/app/page.tsx
import { Button, Card } from '@myorg/ui';
import { formatDate, formatCurrency } from '@myorg/utils';
export default function HomePage() {
const today = new Date();
const price = 149.99;
return (
<main className="mx-auto max-w-4xl p-8">
<h1 className="mb-6 text-4xl font-bold">My Store</h1>
<div className="mb-8 grid gap-4 sm:grid-cols-2">
<Card title="Today's Date" description={formatDate(today)}>
<Button variant="secondary">View Calendar</Button>
</Card>
<Card title="Featured Product" description={`Price: ${formatCurrency(price)}`}>
<Button variant="primary">Add to Cart</Button>
</Card>
</div>
</main>
);
}The admin app uses the same components:
// apps/admin/app/page.tsx
import { Button, Card } from '@myorg/ui';
import { formatDate, PaginatedResponse } from '@myorg/utils';
export default function AdminDashboard() {
return (
<main className="p-8">
<h1 className="mb-4 text-2xl font-bold">Admin Dashboard</h1>
<p className="mb-6 text-gray-600">Last sync: {formatDate(new Date())}</p>
<div className="flex gap-3">
<Button variant="primary">Export Data</Button>
<Button variant="danger">Clear Cache</Button>
</div>
</main>
);
}TypeScript resolves the imports through path aliases in tsconfig.base.json that Nx configured automatically.
Step 6: Explore the Project Graph
Nx builds a live dependency graph of your workspace. Visualize it:
npx nx graphThis opens a browser UI where you can see:
web→ depends onuiandutilsadmin→ depends onuiandutilsui→ no dependenciesutils→ no dependencies
This graph is how Nx knows what to rebuild when files change. If you modify libs/utils/src/lib/format.ts, Nx knows both web and admin are downstream and must be retested.
Step 7: Run Tasks Across the Workspace
Nx provides a consistent interface for running any task across any project:
# Run a single project
npx nx build web
npx nx test ui
npx nx lint utils
# Run a task across all projects
npx nx run-many -t build
npx nx run-many -t test
npx nx run-many -t lint
# Run multiple tasks in parallel
npx nx run-many -t build,test,lintLocal caching means repeated builds complete instantly:
> nx build web
✔ nx run utils:build (231ms)
✔ nx run ui:build (1.2s)
✔ nx run web:build (4.1s)
> nx build web [second run]
✔ nx run utils:build [local cache] (0s)
✔ nx run ui:build [local cache] (0s)
✔ nx run web:build [local cache] (0s)
Step 8: Nx Affected — The CI Game Changer
This is Nx's most powerful feature. Instead of rebuilding and retesting everything on every commit, nx affected only processes projects touched by your changes.
# See what changed vs main
npx nx affected -t build --base=main --head=HEAD
# Run tests only for affected projects
npx nx affected -t test --base=main
# Run lint, test, and build for affected
npx nx affected -t lint,test,build --base=mainExample scenario: You fix a bug in libs/utils. Nx computes:
utils— changed directly ✓ rebuildweb— depends onutils✓ rebuildadmin— depends onutils✓ rebuildui— no dependency onutils✗ skip
If your repo had 10 apps, only the ones depending on utils would run.
GitHub Actions CI 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 # Full history required for nx affected
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Set NX_BASE for PR
if: github.event_name == 'pull_request'
run: echo "NX_BASE=origin/${{ github.base_ref }}" >> $GITHUB_ENV
- name: Set NX_BASE for push
if: github.event_name == 'push'
run: echo "NX_BASE=HEAD~1" >> $GITHUB_ENV
- run: npx nx affected -t lint,test,build --base=$NX_BASEThe fetch-depth: 0 is non-negotiable — Nx compares against the base branch using git history.
Step 9: Nx Cloud for Remote Caching
Local caching helps you, but Nx Cloud shares the cache with your entire team and CI:
npx nx connectFollow the prompts to link your workspace. Once connected:
- A teammate runs
nx build web→ result cached in Nx Cloud - CI runs the same build → instant cache hit, zero compute
- You pull latest and run → instant cache hit
The free tier covers open-source projects and small teams. Check nx.json after connecting:
{
"nxCloudAccessToken": "YOUR_TOKEN_HERE",
"tasksRunnerOptions": {
"default": {
"runner": "nx-cloud",
"options": {
"cacheableOperations": ["build", "test", "lint", "e2e"]
}
}
}
}Step 10: Generators for Consistent Scaffolding
Nx generators are like code templates your team runs instead of manually creating files.
Generate a new component in the UI library:
npx nx generate @nx/react:component badge \
--project=ui \
--directory=src/lib/badge \
--exportThis creates badge.tsx, badge.spec.tsx, and updates index.ts automatically.
Generate a new page in an app:
npx nx generate @nx/next:page pricing \
--project=web \
--directory=app/pricingYou can also write custom generators for your team's patterns. For example, a generator that creates a full CRUD page with TypeScript types, API route, and tests:
npx nx generate @nx/plugin:plugin my-generators --directory=tools/my-generators
npx nx generate @nx/plugin:generator crud-page --project=my-generatorsTesting Your Implementation
Run through this checklist to verify your workspace:
# 1. Install all dependencies
pnpm install
# 2. Build shared libraries
npx nx build ui
npx nx build utils
# 3. Serve web app — should display shared Button and Card
npx nx serve web
# Visit http://localhost:3000
# 4. Serve admin app
npx nx serve admin
# Visit http://localhost:4200
# 5. Run all tests
npx nx run-many -t test
# 6. Check that TypeScript is happy
npx nx run-many -t typecheck
# 7. View the dependency graph
npx nx graphIf the web app loads and shows the Button and Card components with formatted date and price, everything is working.
Troubleshooting
TypeScript cannot find @myorg/ui or @myorg/utils
Open tsconfig.base.json in the root and verify the path aliases exist:
{
"compilerOptions": {
"paths": {
"@myorg/ui": ["libs/ui/src/index.ts"],
"@myorg/utils": ["libs/utils/src/index.ts"]
}
}
}If they're missing, rerun the library generator — it should add them automatically.
nx affected marks everything as affected
This happens when Nx cannot find the comparison base. Ensure your CI checkout uses fetch-depth: 0. On a local branch, run:
git fetch origin main
npx nx affected -t build --base=origin/mainBuild cache is stale or corrupt
Clear the local cache and retry:
npx nx reset
npx nx build webpnpm fails with peer dependency errors
Nx plugins sometimes require specific React or Next.js peer versions. Add to package.json:
{
"pnpm": {
"peerDependencyRules": {
"allowedVersions": {
"react": "19"
}
}
}
}Next Steps
Now that your monorepo is running, consider these extensions:
- Add a backend: Generate a Hono or NestJS app in
apps/apiand create a@myorg/shared-typeslibrary for request/response types - Storybook integration: Run
nx generate @nx/storybook:configuration uito add a component showcase - Module federation: Use
@nx/module-federationto split large apps into independent micro-frontends that share runtime code - Playwright E2E: Add end-to-end tests with
nx generate @nx/playwright:configuration --project=web-e2e - Explore Nx plugins: Database migrations, Docker builds, and deployment can all be integrated through community plugins
Conclusion
You've built a production-ready Nx monorepo where two Next.js applications share UI components and utility functions without duplication. The core concepts to take away:
- Shared libraries keep changes atomic — fix once, benefit everywhere
- The project graph makes all dependencies explicit and queryable
- Nx Affected eliminates wasted CI time as your codebase grows — only what changed gets processed
- Generators enforce consistent patterns so every developer on the team follows the same conventions
- Nx Cloud extends caching from local to the entire team
Monorepos shine especially as teams grow: rather than coordinating breaking changes across multiple repos and npm publishes, a single commit updates everything atomically. Nx makes that coordination invisible.