MT

TypeScript

Breaking Down Libraries in Nx Monorepos with TypeScript Project References

Discover how to modularize libraries in Nx monorepos using TypeScript project references and custom plugins for cleaner, scalable architecture.

April 7, 2025

This is the second article in a series on TypeScript project references in Nx monorepos and PNPM workspaces. You can read the previous article here.

Quick Recap

In the previous part of the series we covered:

  • How to create a workspace using the ts preset
  • How to configure Nx and leverage automatic sync change
  • Benefits of using Nx and TypeScript project references in PNPM workspaces

In this article we’ll explore how to create and consume packages in an Nx monorepo using TypeScript Project References.

We’ll also set up a custom workspace plugin to automate package creation, enforce best practices, and enhance developer experience.

Learning Nx by Example: Breaking Down shadcn/ui

To keep things practical, we’ll use shadcn/ui as a familiar codebase to demonstrate how we can organize and manage workspaces. Instead of using the CLI or copy-pasting components, we’ll break the library into modular packages using React, Tailwind css, and Radix UI.

The cn Utility

Before diving into individual components, it’s worth identifying shared logic we’ll reuse across packages. The cn function from shadcn/ui it's widely used to merge class names using clsx and tailwind-merge, which makes it a strong candidate for the @nx-prefs/utils package.

  1. Install the required dependencies:
pnpm add -D clsx tailwind-merge -F @nx-prefs/utils
Installing clsx and tailwind-merge.
  1. Replace the contents of utils.tsx:
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
packages/utils/src/lib/utils.tsx.
  1. Update the test suite on utils.spec.tsx:
import { cn } from './utils'

describe('Utils', () => {
  it('should handle conditional class names', () => {
    const ifTrue = false
    const result = cn('class1', ifTrue && 'class2', 'class3')
    expect(result).toBe('class1 class3')
  })

  it('should merge tailwind classes correctly', () => {
    const result = cn('bg-red-500', 'bg-blue-500')
    expect(result).toBe('bg-blue-500')
  })
})
packages/utils/src/lib/utils.spec.tsx.

List, Test, and Build

We can leverage nx run-many to run all relevant checks and make sure everything works as expected:

nx run-many -t lint,test,build -p utils
Lint, test and build utils.

Project vs Package Naming

In an Nx workspace, "project" and "package" are related but distinct concepts. Projects are the highest-level entities within the workspace, (e.g. apps, libs or plugins), while packages are the units of code that can be shared and reused across projects.

By now, you probably noticed the distinction between Nx project names (utils) and package names (@nx-prefs/utils). Depending on your preferences, using the same name for both can be a safe default.

Configuring Next.js with TailwindCSS

Let’s configure our Next.js app to use TailwindCSS. First, install the required dependencies:

pnpm add -D tailwindcss @tailwindcss/postcss -F sandbox

Next, add a postcss.config.mjs file at the root of the sandbox app:

/* eslint-disable import/no-anonymous-default-export */
/** @type {import('postcss-load-config').Config} */
export default {
  plugins: {
    '@tailwindcss/postcss': {},
  },
}
apps/sandbox/postcss.config.mjs.

Lastly, remove the content of global.css and import tailwindcss:

@import 'tailwindcss';
apps/sandbox/src/app/global.css.

Updating the App to Use cn

Let’s see if we can use the cn function. We’ll render two headings with the same classes—one using raw class names, and the other using cn. The cn function should correctly merge the styles so that the colors match the text content.

import { cn } from '@nx-prefs/utils'

export default function Home() {
  return (
    <section className="w-full h-screen flex items-center justify-center">
      <h1 className="text-red-500 text-blue-500">Red</h1>
      <h1 className={cn('text-red-500 text-blue-500')}>Blue</h1>
    </section>
  )
}
apps/sandbox/src/app/page.tsx.

Now, let’s make sure everything is wired up correctly. You should be able to lint, test, and build both the utils and sandbox projects by running:

nx run-many -t lint,test,build

Run the sandbox app:

nx dev sandbox

The application should look like this:

Sandbox App Preview

sandbox app preview.

Creating the Button Package

Now let’s walk through creating another package—@nx-prefs/button. At this point, the steps should feel familiar to you.

  1. Generate the package using @nx/react plugin:
nx g @nx/react:library --directory=packages/button \
  --name=button \
  --bundler=vite \
  --linter=eslint \
  --unitTestRunner=vitest \
  --importPath=@nx-prefs/button \
  --useProjectJson=true \
  --no-interactive
Generating button project.
  1. Install external dependencies:
pnpm add @radix-ui/react-slot class-variance-authority -F button
Installing button dependencies.
  1. Install internal dependencies, such as @nx-prefs/utils:
pnpm add @nx-prefs/utils --filter button --workspace
Intalling @nx-prefs/utils.
  1. Replace the contents of button.tsx with shadcn/ui's button component:
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'

import { cn } from '@/lib/utils' // change to `@nx-prefs/utils`

const buttonVariants = cva(...) // redacted for brevity

function Button({
  className,
  variant,
  size,
  asChild = false,
  ...props
}: React.ComponentProps<'button'> &
  VariantProps<typeof buttonVariants> & {
    asChild?: boolean
  }) {
  const Comp = asChild ? Slot : 'button'

  return (
    <Comp
      data-slot="button"
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  )
}

export { Button, buttonVariants }
packages/button/src/lib/button.tsx.
  1. Remove unused files, such as button.module.css, and update the test suite on button.spec.tsx:
import { render } from '@testing-library/react'

import { Button } from './button'

describe('Button', () => {
  it('should render successfully', () => {
    const { getByRole } = render(<Button>Click me</Button>)
    expect(getByRole('button')).toBeTruthy()
  })

  it('should render with the correct text', () => {
    const { getByText } = render(<Button>Click me</Button>)
    expect(getByText('Click me')).toBeTruthy()
  })
})
packages/button/src/lib/button.spec.tsx.
  1. Lint, test, and build the button project:
nx run-many -t lint,test,build -p button
Running checks on button.

If you followed the previous article, Nx has automatically synced your workspace. If not, when prompted, hit Enter.

To manually sync your workspace, run nx sync from the root of your workspace.

Adding shadcn/ui Styles

To see our <Button /> variants in action, we need to update global.css to include the shadcn/ui’s theme variables and default styles.

We’ll also need to configure Tailwind v4.x to recognize and load styles from our package, which can be done using the @source directive:

@import 'tailwindcss';

@source "../../node_modules/@nx-prefs/button";

@custom-variant dark (&:is(.dark *));

@theme inline {
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-popover: var(--popover);
  --color-popover-foreground: var(--popover-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-accent: var(--accent);
  --color-accent-foreground: var(--accent-foreground);
  --color-destructive: var(--destructive);
  --color-border: var(--border);
  --color-input: var(--input);
  --color-ring: var(--ring);
}

:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.145 0 0);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --secondary: oklch(0.97 0 0);
  --secondary-foreground: oklch(0.205 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --accent: oklch(0.97 0 0);
  --accent-foreground: oklch(0.205 0 0);
  --destructive: oklch(0.577 0.245 27.325);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --card: oklch(0.205 0 0);
  --card-foreground: oklch(0.985 0 0);
  --popover: oklch(0.269 0 0);
  --popover-foreground: oklch(0.985 0 0);
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
  --secondary: oklch(0.269 0 0);
  --secondary-foreground: oklch(0.985 0 0);
  --muted: oklch(0.269 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --accent: oklch(0.371 0 0);
  --accent-foreground: oklch(0.985 0 0);
  --destructive: oklch(0.704 0.191 22.216);
  --border: oklch(1 0 0 / 10%);
  --input: oklch(1 0 0 / 15%);
  --ring: oklch(0.556 0 0);
}

@layer base {
  * {
    @apply border-border outline-ring/50;
  }
  body {
    @apply bg-background text-foreground;
  }
}
Updated global.css.

Enforcing Consistency Across Packages

The previous steps will be pretty much the same for all future component packages: generate the library, install the usual dependencies, clean up boilerplate, update imports, tweak tests, and so on.

This process—while straightforward—can be repetitive and prone to inconsistency over time, especially across large teams or growing codebases.

What if instead of memorizing multi-line nx generate commands, and rewriting boilerplate, we could define our own tailored setup once—and reuse it forever?

That’s where Nx workspace plugins come in. ✨

Custom plugins can enforce consistent naming and configuration, and generate README files or component templates. We can wrap all those repetitive steps and best practices into a single, streamlined generator.

What Are Nx Plugins?

Nx plugins can extend the capabilities of our workspace by providing built-in support for specific frameworks, tools, and workflows. A plugin typically includes:

  • Executors – to define how tasks like build, test, or serve are run.
  • Generators – to scaffold code, configure tools, and automate setup.
  • Project Configurations – to help integrate projects into the workspace.

From Nx's official documentation:

Nx plugins help developers use a tool or framework with Nx. They allow the plugin author who knows the best way to use a tool with Nx to codify their expertise and allow the whole community to reuse those solutions.

Plugins can be official (e.g. @nx/react, @nx/next, etc.), community-driven, or custom-built. They help enforce consistency, reduce setup time, and enhance developer productivity by encapsulating best practices into reusable pieces.

Executors

Executors are what actually perform tasks like building, testing, serving, or linting a project. They’re defined in each project's project.json or package.json files, and can also infer tasks automatically based on the configurations files of specific tools.

In this example, @nx/web:build is the executor that tells Nx how to build a web application:

{
  "targets": {
    "build": {
      "executor": "@nx/web:build",
      "options": {
        "outputPath": "dist/apps/my-app",
        ...
      }
    }
  }
}
Example executor configuration.

We can also write our own custom executors to define how tasks should run for specific tooling or workflows.

Generators

Generators are automated code scaffolding tools. They can create files, configure dependencies, and update existing code to help us set up new projects, features, or libraries with ease.

Generators help us save time by enforcing consistent patterns and reducing manual setup and configuration. For example:

nx g @nx/react:component my-component -p my-lib-or-app
Example generator command.

This would generate a new React component named MyComponent inside the my-lib-or-app project. Generators can be customized or created from scratch to fit your team's conventions.

Building a Custom Nx Plugin

Let’s walk through the process of building a custom Nx plugin to scaffold libraries. We'll use the @nx/plugin package to help us create and customize the plugin.

1. Setting Up the Plugin

Start by installing the required package:

pnpm add -D @nx/plugin -w
Installing plugin dependencies.

Then generate the plugin:

nx g @nx/plugin:plugin \
  --name=plugin \
  --linter=eslint \
  --unitTestRunner=jest \
  --useProjectJson=true \
  --directory=packages/plugin \
  --importPath=@nx-prefs/plugin \
  --no-interactive
Creating workspace plugin.

This will create a new package named plugin at packages/plugin, which can be imported as @nx-prefs/plugin.

If you run into a Project Graph Error, reset the Nx cache and stop the daemon by running:

nx reset
Reloading Nx daemon.

2. Creating the Library Generator

Now, create a generator named library inside the plugin:

nx g @nx/plugin:generator \
  --path=packages/plugin/src/generators/library/generator \
  --no-interactive
Creating a generator.

This creates the following file structure:

...
generators
└── library
    ├── files
       └── index.ts.template
    ├── generator.spec.ts
    ├── generator.ts
    ├── schema.d.ts
    └── schema.json
packages/plugin/src tree.

What's Inside:

  • files/ - Contains EJS templates used to scaffold our package files dynamically.

  • generator.ts - Contains the generator’s logic: file generation, configuration, and automation.

  • schema.d.ts: Conventionally used to define the input option types for the generator.

  • schema.json: Specifies and validates input options, providing support for both CLI usage and Nx Console integration.

3. Improving Developer Experience

By default, the generator requires you to manually set options like name, directory, bundler, linter, test runner, and import path, which can be error-prone.

We can simplify the developer experience by:

  • Making directory optional with a sensible default.
  • Setting bundler, linter, test runner, and import path automatically.
  • Adding a README template to every generated package.

Example Usage

After improving our generator, we’ll be able to run:

nx g @nx-prefs/plugin:library alert

Instead of:

nx g @nx/react:library --directory=packages/alert \
  --name=alert \
  --bundler=vite \
  --linter=eslint \
  --unitTestRunner=vitest \
  --importPath=@nx-prefs/alert \
  --useProjectJson=true \
  --no-interactive

4. Defining the Generator Schema

Start by defining our library generator's schema:

export interface LibraryGeneratorSchema {
  name: string
  directory?: string
}
Updated schema.d.ts.

Then update schema.json to reflect the same structure:

{
  "$schema": "https://json-schema.org/schema",
  "$id": "nx-prefs-plugin-library-generator",
  "title": "Nx PRefs Library Generator",
  "type": "object",
  "properties": {
    "name": {
      ...
    },
    "directory": {
      "type": "string",
      "description": "The directory of the new library."
    }
  },
  "required": ["name"]
}
Updated schema.json.

5. Implementing Generator Logic

Now that the schema is defined, let’s implement the generator logic. We’ll also define a normalizeOptions helper to centralize option handling and compute values for the templates.

Add a new type schema.d.ts to define the variables used by our templates:

export interface LibraryGeneratorVariables {
  name: string
  importPath: string
  projectRoot: string
  offsetFromRoot: string
}
Updated schema.d.ts.

Then replace the contents of generator.ts:

import {
  addProjectConfiguration,
  formatFiles,
  generateFiles,
  offsetFromRoot,
  Tree,
} from '@nx/devkit'
import * as path from 'path'
import { LibraryGeneratorSchema, LibraryGeneratorVariables } from './schema'

function normalizeOptions(
  options: LibraryGeneratorSchema
): LibraryGeneratorVariables {
  const name = String(options.name).toLowerCase()
  const importPath = `@nx-prefs/${name}`
  const projectRoot = options.directory ?? `packages/${name}`

  return {
    name,
    importPath,
    projectRoot,
    offsetFromRoot: offsetFromRoot(projectRoot),
  }
}

export async function libraryGenerator(
  tree: Tree,
  options: LibraryGeneratorSchema
) {
  const { projectRoot, ...variables } = normalizeOptions(options)

  addProjectConfiguration(tree, options.name, {
    root: projectRoot,
    projectType: 'library',
    sourceRoot: `${projectRoot}/src`,
    targets: {},
  })

  generateFiles(tree, path.join(__dirname, 'files'), projectRoot, {
    projectRoot,
    ...variables,
  })

  await formatFiles(tree)
}

export default libraryGenerator
Updated generator.ts.

We can now use variables like <%= name %>, <%= importPath %>, and <%= offsetFromRoot %> to dynamically inject values.

6. Creating File Templates

Our plugin's logic is complete, but we still have to update the template files. Files with one or more ellipses (...) have been redacted for brevity.

Copy the structure from an existing library like button into our generator’s files/ directory, excluding the project.json.

Add the .template suffix to each file:

files/
├── .babelrc.template
├── eslint.config.mjs.template
├── package.json.template
├── README.md.template
├── tsconfig.json.template
├── tsconfig.lib.json.template
├── tsconfig.spec.json.template
└── vite.config.ts.template
files directory tree.

files/src/index.ts.template

Update both variable's name and value, or copy and paste the following.

export const <%= name %> = "Hello from <%= name %>!";

files/eslint.config.mjs.template

Update the baseConfig import.
import nx from '@nx/eslint-plugin'
import baseConfig from '<%= offsetFromRoot + "eslint.config.mjs" %>'

export default [
  ...
]

files/package.json.template

Update the name property, remove dependencies, and optionally add peerDependencies as shown.

{
  "name": "<%= importPath %>",
  "dependencies": {}, // remove any dependencies here
  "devDependencies": {},
  "peerDependencies": {
    "@types/react": "^18.3.1",
    "@types/react-dom": "^18.3.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },
  "peerDependenciesMeta": {
    "@types/react": {
      "optional": true
    },
    "@types/react-dom": {
      "optional": true
    }
  }
}

files/README.md.template

Add name and importPath where needed, or simply copy and paste the template below.

# <%= importPath %>

The `<%= name %>` package provides a customizable and reusable component for your application. It is designed to be lightweight and easy to integrate into any project.

## Installation

To install the package, use the following command:

```bash
npm install <%= importPath %>
```

## Usage

Import the component into your project and use it as follows:

```tsx
import { Component } from '<%= importPath %>'

function Example() {
  return <Component />
}
```

## Running unit tests

To execute the unit tests via [Vitest](https://vitest.dev/), run:

```sh
nx test <%= name %>
```

files/tsconfig.json.template

Update the extends property as shown.
{
  "files": [],
  "include": [],
  "references": [
    ...
  ],
  "extends": "<%= offsetFromRoot + "tsconfig.base.json" %>"
}

files/tsconfig.lib.json.template

Update the extends property as shown and remove any references.
{
  "extends": "<%= offsetFromRoot + "tsconfig.base.json" %>",
  "compilerOptions": { ... },
  "exclude": [ ... ],
  "include": [ ... ],
  "references": [] // remove any references here
}

files/tsconfig.spec.json.template

Update the extends property as shown and keep everything else as is.

{
  "extends": "<%= offsetFromRoot + "tsconfig.base.json" %>",
  "compilerOptions": { ... },
  "include": [ ... ],
  "references": [ ... ]
}

files/vite.config.ts.template

Update properties cacheDir and name (the latter under build.lib) inside defineConfig.

/// <reference types='vitest' />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import dts from 'vite-plugin-dts'
import * as path from 'path'

export default defineConfig(() => ({
  root: __dirname,
  cacheDir: '<%= offsetFromRoot + "node_modules/.vite/packages/" + name %>',
  plugins: [ ... ],
  build: {
    ...
    lib: {
      entry: 'src/index.ts',
      name: '<%= name %>',
      fileName: 'index',
      formats: ['es' as const],
    },
    ...
  },
}))

Final Plugin Setup

Add to packages/plugins/src/index.ts:

import { NxPlugin } from '@nx/devkit'

const plugin: NxPlugin = {
  name: '@nx-prefs/plugin',
}

export = plugin
Updated index.ts.

Note: There may be a regression in @nx/plugin:generator that incorrectly sets the generator name in generators.json. This may already be fixed in versions later than v20.7.0, but if not, update it manually.

Replace packages/plugins/generators.json with:

{
  "generators": {
    "library": {
      "factory": "./dist/generators/library/generator",
      "schema": "./dist/generators/library/schema.json",
      "description": "NX PRefs Library Generator"
    }
  }
}
Fixed generators.json.

Generator Test Suite

Update generator.spec.ts to match our implementation:

import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'
import { Tree, readProjectConfiguration } from '@nx/devkit'

import { libraryGenerator } from './generator'
import { LibraryGeneratorSchema } from './schema'

describe('library generator', () => {
  let tree: Tree
  const options: LibraryGeneratorSchema = { name: 'test' }

  beforeEach(() => {
    tree = createTreeWithEmptyWorkspace()
  })

  it('should run successfully', async () => {
    await libraryGenerator(tree, options)
    const config = readProjectConfiguration(tree, 'test')
    expect(config).toBeDefined()
  })
})
Updated generator.spec.ts.

Then verify that all targets run successfully:

nx run-many -t lint,test,build -p plugin
Running plugin checks.

We are now ready to generate packages using our custom plugin! 🎉

Consuming Your Plugin

To scaffold a new alert package:

nx g @nx-prefs/plugin:library alert

Add it to the sandbox app:

pnpm add @nx-prefs/alert --filter sandbox --workspace

Then import and use it inside page.tsx:

import { alert } from '@nx-prefs/alert'
import { Button } from '@nx-prefs/button'

export default function Home() {
  return (
    <section className="w-full h-screen flex flex-col items-center justify-center">
      <Button>{alert}</Button>
    </section>
  )
}
apps/sandbox/src/app/page.tsx

If everything is wired up correctly, you should see:

Sandbox App Preview

sandbox app preview.

🥳 Congratulations! You've just created your own Nx plugin and successfully used it to scaffold and integrate a new package into a monorepo app.

Key Takeaways

  • Consistency matters - stick to conventions for naming, structure, and documentation.
  • Start small - build primitives before moving on to complex compositions.
  • Automate with plugins - reduce boilerplate, increase repeatability.

What's Next (Part 3)

In the next article, we’ll level up our monorepo setup by exploring:

  • Using syncpack to manage dependencies across all packages.
  • Creating a GitHub Actions workflow to lint, test and build everything.
  • Enabling Nx Cloud for remote caching and faster CI/CD pipelines.

The author is not affiliated with the Nx team, and this article is not sponsored. The content presented is based on the author’s personal experience and should not be regarded as a definitive or authoritative source.