Setting up a monorepo with npm can feel like trying to herd cats, especially when you’re juggling multiple packages that depend on each other.
Let’s get a real feel for this by looking at a simple, functional Nx workspace setup. Nx is a build system that excels at monorepos, and its setup is surprisingly straightforward.
Imagine we have two packages: a shared ui library and an app that uses it.
First, initialize an Nx workspace:
npx create-nx-workspace@latest my-monorepo
cd my-monorepo
This command bootstraps a new Nx workspace. It asks a few questions, but for this example, let’s assume we choose a react application and a library for our packages.
Next, we’ll create our shared UI library. Nx uses plugins for different frameworks and tools. We’ll use the @nx/react plugin for this:
nx g @nx/react:library ui --directory=libs/shared
This command generates a new React library named ui within the libs/shared directory. The nx g (or nx generate) command is Nx’s way of scaffolding code.
Now, let’s create our application that will consume this ui library:
nx g @nx/react:application app --directory=apps/main
This generates a new React application named app inside the apps/main directory.
At this point, your directory structure looks something like this:
my-monorepo/
├── apps/
│ └── main/
│ └── app/
│ └── src/
│ └── ...
├── libs/
│ └── shared/
│ └── ui/
│ └── src/
│ └── ...
├── tools/
├── nx.json
├── package.json
├── tsconfig.base.json
└── ...
The magic happens in nx.json and package.json. nx.json configures how Nx understands your project, including caching, task runners, and affected commands. package.json in the root manages your workspace dependencies.
To make our app use the ui library, we need to import it. Open apps/main/app/src/app/app.tsx and modify it to import from our ui library:
import { NxWelcome } from '@my-monorepo/shared-ui'; // Assuming your library is aliased like this
export function App() {
return (
<div>
<h1>Welcome to my-monorepo!</h1>
<NxWelcome title="shared-ui" />
</div>
);
}
export default App;
Nx automatically sets up package aliases in tsconfig.base.json to handle these inter-package imports. For example, it might have an alias like:
"paths": {
"@my-monorepo/shared-ui": ["libs/shared/ui/src/index.ts"]
}
To build and run your application, you use Nx commands:
nx serve app
nx build app
nx serve app will build and start a development server for your app. nx build app will create a production build. Nx’s powerful caching means that if you only change the ui library, it knows exactly which dependent projects need to be rebuilt.
The core problem this solves is dependency management and build orchestration across multiple related packages. Instead of publishing packages to npm and installing them, they live side-by-side, and Nx understands their relationships.
The most surprising thing about Nx is how it leverages a computation graph. It analyzes your entire project, understanding which files are inputs for which tasks, and builds a directed acyclic graph (DAG) of all operations. This graph allows Nx to parallelize tasks, cache results effectively, and determine precisely which tasks need to be re-run when code changes (the "affected" commands).
Consider how Nx handles task execution and caching. When you run nx build app, Nx doesn’t just blindly execute the build script. It looks at the package.json script for app, identifies the inputs (source files, config files, dependencies), and checks its cache. If a build with the exact same inputs has been performed before, it serves the result directly from the cache. If not, it executes the build, stores the result in the cache, and then serves it. This is fundamental to its speed.
The next concept you’ll likely grapple with is advanced dependency management and task orchestration, particularly when dealing with shared configurations and custom build pipelines across your monorepo.