writing/tutorial/2026/07
TutorialJul 29, 2026·30 min read

Nx Monorepo: Scaling Next.js and TypeScript Projects with Shared Libraries

Learn how to build an Nx monorepo with multiple Next.js applications and shared TypeScript libraries. This guide covers workspace setup, library generation, dependency graphing, and Nx Affected for dramatically faster CI pipelines.

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 application
  • apps/admin — Admin Next.js 15 application
  • libs/ui — Shared React component library (@myorg/ui)
  • libs/utils — Shared TypeScript utilities (@myorg/utils)
  • CI configuration using nx affected to 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 myorg

The --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/js

Step 2: Add Two Next.js Applications

Generate the main web application:

npx nx generate @nx/next:app web \
  --directory=apps/web \
  --style=css \
  --appRouter=true

Generate the admin application:

npx nx generate @nx/next:app admin \
  --directory=apps/admin \
  --style=css \
  --appRouter=true

Verify both work:

npx nx serve web    # runs on http://localhost:3000
npx nx serve admin  # runs on http://localhost:4200

Each 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/ui

The --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/utils

Add 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 graph

This opens a browser UI where you can see:

  • web → depends on ui and utils
  • admin → depends on ui and utils
  • ui → no dependencies
  • utils → 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,lint

Local 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=main

Example scenario: You fix a bug in libs/utils. Nx computes:

  • utils — changed directly ✓ rebuild
  • web — depends on utils ✓ rebuild
  • admin — depends on utils ✓ rebuild
  • ui — no dependency on utils ✗ 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_BASE

The 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 connect

Follow 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 \
  --export

This 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/pricing

You 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-generators

Testing 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 graph

If 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/main

Build cache is stale or corrupt

Clear the local cache and retry:

npx nx reset
npx nx build web

pnpm 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/api and create a @myorg/shared-types library for request/response types
  • Storybook integration: Run nx generate @nx/storybook:configuration ui to add a component showcase
  • Module federation: Use @nx/module-federation to 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.