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
tspreset - 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.
- Install the required dependencies:
Installingpnpm add -D clsx tailwind-merge -F @nx-prefs/utils
clsx and tailwind-merge.
- 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.
- 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:
Lint, test and buildnx run-many -t lint,test,build -p utils
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.
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.
- Generate the package using
@nx/reactplugin:
Generatingnx g @nx/react:library --directory=packages/button \ --name=button \ --bundler=vite \ --linter=eslint \ --unitTestRunner=vitest \ --importPath=@nx-prefs/button \ --useProjectJson=true \ --no-interactive
button project.
- Install external dependencies:
Installingpnpm add @radix-ui/react-slot class-variance-authority -F button
button dependencies.
- Install internal dependencies, such as
@nx-prefs/utils:
Intallingpnpm add @nx-prefs/utils --filter button --workspace
@nx-prefs/utils.
- Replace the contents of
button.tsxwithshadcn/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.
- Remove unused files, such as
button.module.css, and update the test suite onbutton.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.
- Lint, test, and build the
buttonproject:
Running checks onnx run-many -t lint,test,build -p button
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:
Updated@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; } }
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:
Example executor configuration.{ "targets": { "build": { "executor": "@nx/web:build", "options": { "outputPath": "dist/apps/my-app", ... } } } }
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:
Example generator command.nx g @nx/react:component my-component -p my-lib-or-app
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:
Installing plugin dependencies.pnpm add -D @nx/plugin -w
Then generate the plugin:
Creating workspace plugin.nx g @nx/plugin:plugin \ --name=plugin \ --linter=eslint \ --unitTestRunner=jest \ --useProjectJson=true \ --directory=packages/plugin \ --importPath=@nx-prefs/plugin \ --no-interactive
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:
Reloading Nx daemon.nx reset
2. Creating the Library Generator
Now, create a generator named library inside the plugin:
Creating a generator.nx g @nx/plugin:generator \ --path=packages/plugin/src/generators/library/generator \ --no-interactive
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
directoryoptional 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:
Updatedexport interface LibraryGeneratorSchema { name: string directory?: string }
schema.d.ts.
Then update schema.json to reflect the same structure:
Updated{ "$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"] }
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:
Updatedexport interface LibraryGeneratorVariables { name: string importPath: string projectRoot: string offsetFromRoot: string }
schema.d.ts.
Then replace the contents of generator.ts:
Updatedimport { 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
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:
Updatedimport { NxPlugin } from '@nx/devkit' const plugin: NxPlugin = { name: '@nx-prefs/plugin', } export = plugin
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:
Fixed{ "generators": { "library": { "factory": "./dist/generators/library/generator", "schema": "./dist/generators/library/schema.json", "description": "NX PRefs Library Generator" } } }
generators.json.
Generator Test Suite
Update generator.spec.ts to match our implementation:
Updatedimport { 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() }) })
generator.spec.ts.
Then verify that all targets run successfully:
Runningnx run-many -t lint,test,build -p plugin
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.
🥳 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
syncpackto 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.