‹ Blog
Juri Strumpflohner
Juri StrumpflohnerJuri Strumpflohner

Exploring Polyglot Monorepos with Nx, TanStack and Rust

Polyglot monorepos, that is, monorepos with multiple different tech stacks, are going to be more prevalent. The main driver is agentic development, where I've seen two main trends recently:

  • AI agents shine in monorepos. That's why more people start thinking about migrating. Classic example: consolidate their frontend and backend into one monorepo
  • The entry barrier to new languages is way lower. AI agents can take a lot of the maintenance overhead and lower-level coding work.

Nx has had multi-language support for a while: .NET, Java, Rust as well as Python. I'll have a series on these coming up soon, but for this one I'd like to focus on Rust.

I wanted to go further though and see how far I can push an agent to do it. So I tasked Codex to:

  • take an existing PNPM workspace monorepo with a TanStack Start app
  • make it an Nx monorepo
  • add Rust to the stack, in particular by adding a Topcoat based web application
  • share parts between the JavaScript webapp and the Rust part
  • configure CI with Nx Cloud and Nx Agents

This article walks through the setup (or just watch the linked video). The full workspace is also on GitHub at juristr/nx-polyglot-rust. Just point your agent at it to explore.

Our existing pnpm monorepo

The starting point is a PNPM workspace which is a common approach to start with a JS-based monorepo.

A TanStack Start application is in apps/web with packages living in packages/.

pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
  - 'packages/*/*'

I also added Nx on top (just run nx init). Why is adding Nx important? Nx gives us a common task runner that can run both JavaScript and Rust based targets. PNPM is for JS workspaces, so it is able to trigger package.json scripts, but not necessarily Cargo aliases. We can obviously force PNPM into triggering those commands, but I'd rather not.

Adding Rust to the workspace

So how do we install Rust into the workspace? I went with mise. I've been using it for quite a while to manage all sorts of tooling installation and versioning.

What mise does here is to install the Rust toolchain in this workspace by driving rustup which then brings in Cargo, rustc and clippy at the defined pinned version. The advantage: by also using mise in CI later, I avoid all sorts of local vs CI version conflicts.

How packages are linked in a Rust monorepo

Rust has Cargo (which could be compared to PNPM + package.json scripts in the JS world). Cargo has its own workspace concept. The root Cargo.toml lists the members and centralizes shared dependency versions, including the path dependencies between local crates (the Rust packages):

Cargo.toml
[workspace]
resolver = "2"
members = [
  "apps/topcoat-security",
  "packages/shared/security-contract",
  "packages/topcoat/security-domain",
  "packages/topcoat/security-store",
  "packages/topcoat/security-ui",
]

[workspace.dependencies]
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread", "sync", "time"] }
topcoat = { version = "0.5.0", features = ["sse"] }
topcoat-security-domain = { path = "packages/topcoat/security-domain" }
topcoat-security-store = { path = "packages/topcoat/security-store" }

Individual crates then consume those entries with .workspace = true:

packages/topcoat/security-store/Cargo.toml
[dependencies]
topcoat.workspace = true
topcoat-security-domain.workspace = true

This is the direct counterpart to workspace:* on the pnpm side.

Note, Nx does not get involved in any of it, just like PNPM does the linking for JS packages, Cargo resolves and links the crates.

Teaching Nx to run Rust targets

Rust support comes from the @monodon/rust plugin, registered in nx.json.

You can run the following command to add it to your workspace.

nx add @monodon/rust

After that you'll see the plugin being registered in the nx.json:

{
  "$schema": "./node_modules/nx/schemas/nx-schema.json",
  ...
  "plugins": [
    "@monodon/rust"
  ]
}

The plugin is like an adapter that exposes Cargo operations to Nx so it detects them as runnable targets. Very much like how Nx automatically detects package.json script targets.

As a result, running nx build <rust-project> is going to trigger cargo build, test runs cargo test, lint runs cargo clippy and run runs cargo run.

What is an Nx plugin?

An Nx plugin is a package that teaches Nx about a tool it does not understand natively. It reads the configuration files that tool already uses, a Cargo.toml, a pom.xml, a vite.config.ts, and derives projects, targets and dependencies from them, so caching and affected work without you writing that wiring by hand.

Important to note: Plugins are optional. Any folder containing a project.json is already an Nx project. As such, you can also manually add a project.json to a Rust project and define the targets to run, like:

  {
    "name": "some-rust-package",
    "sourceRoot": "packages/.../src",
    "targets": {
      "build": {
        "command": "cargo build -p some-rust-package",
        "options": { "cwd": "{workspaceRoot}" }
      }
    }
  }

A plugin can just improve the developer ergonomics.

See Nx plugins, multi-language support and the plugin registry for what already exists.

JavaScript projects declare their tasks through package.json scripts, which Nx picks up directly. Rust crates have no package.json, so they declare targets in a project.json:

apps/topcoat-security/project.json
{
  "name": "topcoat-security",
  "projectType": "application",
  "tags": ["scope:topcoat", "type:app", "lang:rust"],
  "targets": {
    "build": {
      "executor": "@monodon/rust:build",
      "outputs": ["{options.target-dir}"],
      "options": { "target-dir": "dist/target/topcoat-security/build" }
    },
    "test": {
      "executor": "@monodon/rust:test",
      "outputs": ["{options.target-dir}"],
      "options": { "target-dir": "dist/target/topcoat-security/test" }
    },
    "lint": {
      "executor": "@monodon/rust:lint",
      "outputs": ["{options.target-dir}"],
      "options": { "target-dir": "dist/target/topcoat-security/lint" }
    },
    "run": {
      "continuous": true,
      "executor": "@monodon/rust:run",
      "outputs": ["{options.target-dir}"],
      "options": { "target-dir": "dist/target/topcoat-security/run" }
    }
  }
}

"executor": "@monodon/rust:build" is where the hand-off to the underlying @monodon/rust plugin's build target happens.

Running tasks the same way across both stacks

What I personally love about this setup is that you can now type the following command and Nx just runs all of them across the entire codebase:

pnpm nx run-many -t build lint test typecheck

This means running Cargo, Vite, tsc, ESLint, etc. in the correct dependency order, including caching them.

Task pipelines across the two stacks

Nx doesn't really care here what underlying tool is being invoked. At the task level all of them are the same, which means existing concepts such as defining a task pipeline just work.

Here for instance we define that whenever we start the TanStack Start app (with pnpm nx dev web), we want to automatically also start the Rust Topcoat web app (topcoat-security):

apps/web/package.json
{
  "nx": {
    "targets": {
      "dev": {
        "continuous": true,
        "dependsOn": [{ "projects": ["topcoat-security"], "target": "run" }]
      }
    }
  }
}

This translates to the following task pipeline:

web:dev
  -> topcoat-security:run (continuous)
       -> security-globe:vite:build

Nx terminal output for web:dev, showing topcoat-security:run and web:dev both marked Continuous, above the three dependency builds Nx restored from cache

Nx caching for Rust

Nx run-many output with every task reporting existing outputs match the cache, mixing Rust tasks like topcoat-security-store:build with JavaScript ones like dashboard-ui:test, and a summary line reading 23 out of 23 tasks read from cache in 354ms

Caching for Rust targets just works in Nx. We just need to define what we want to have cached and what the inputs and outputs are.

nx.json
{
  "namedInputs": {
    "rust": [
      "default",
      "{workspaceRoot}/Cargo.toml",
      "{workspaceRoot}/Cargo.lock",
      "{workspaceRoot}/rust-toolchain.toml",
      "{workspaceRoot}/.mise.toml",
      "{workspaceRoot}/pnpm-lock.yaml"
    ]
  },
  "targetDefaults": {
    "@monodon/rust:build": {
      "cache": true,
      "inputs": ["rust", "^rust"],
      "dependsOn": ["^build"]
    },
    "@monodon/rust:test": {
      "cache": true,
      "inputs": ["rust", "^rust"],
      "dependsOn": ["^build"]
    },
    "@monodon/rust:lint": { "cache": true, "inputs": ["rust", "^rust"] }
  }
}

The other Rust-specific detail is the per-target target-dir in the project.json above. Cargo writes into one shared target/ directory by default. Point build, test, lint and run at it and they overwrite each other's artifacts. Giving each target its own directory under dist/target/<project>/<target> fixes that.

CI on Nx Cloud

Configuring a monorepo shouldn't stop at the local setup though, so I tasked Codex to set up the monorepo, and also told it to configure Nx Cloud as the CI machinery, including using Nx Agents. None of that part is Rust specific by the way, the setting up CI guide walks through it for any Nx workspace.

Here's what I got:

.github/workflows/ci.yml
jobs:
  checks:
    name: Nx checks
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v5
        with:
          fetch-depth: 0
          filter: tree:0

      # Start agents as early as possible so they boot while this job installs.
      # Distribution config lives in .nx/ci-config.yaml
      - name: Start Nx Cloud agents
        run: npx nx-cloud start-nx-agents

      - name: Setup Rust with mise
        uses: jdx/mise-action@v3

      - name: Setup pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 11.0.6

      - name: Setup Node.js
        uses: actions/setup-node@v5
        with:
          node-version: 24
          cache: pnpm

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Set affected SHAs
        uses: nrwl/nx-set-shas@v5

      - name: Run Nx tasks
        run: pnpm exec nx affected -t lint test build typecheck

A couple of things worth noting here:

npx nx-cloud start-nx-agents - this is where we instruct Nx Cloud to start capturing tasks for distributing them across various machines with Nx Agents. The configuration for that is in .nx/ci-config.yaml:

.nx/ci-config.yaml
dte:
  distribute-on: 2 linux-medium-polyglot
lifecycle:
  stop-after:
    - lint
    - test
    - build
    - typecheck

The ci-config reference lists the full set of keys.

uses: jdx/mise-action@v3 - Notice how we again use mise to install the Rust toolchain. We'll see this again when configuring the Nx Cloud launch template later.

nrwl/nx-set-shas@v5 - This sets the base SHA for nx affected command (see next one in the config) to the last successful run on main. Nx affected compares two sets of shas and determines which projects changed and then schedules the tasks to run only for those.

How to run Rust on Nx Agents

Nx Agents are machines that Nx Cloud distributes the tasks across. To be able to run Rust we need to create a custom launch template (which Codex did just nicely) in .nx/workflows/agents.yaml:

.nx/workflows/agents.yaml
common-init-steps: &common-init-steps
  - name: Checkout
    uses: 'nrwl/nx-cloud-workflows/v6/workflow-steps/checkout/main.yaml'

  - name: Restore pnpm cache
    uses: 'nrwl/nx-cloud-workflows/v6/workflow-steps/cache/main.yaml'
    inputs:
      key: 'pnpm-lock.yaml'
      paths: '~/.local/share/pnpm/store'
      base-branch: 'main'

  - name: Restore Cargo cache
    uses: 'nrwl/nx-cloud-workflows/v6/workflow-steps/cache/main.yaml'
    inputs:
      key: 'Cargo.lock|rust-toolchain.toml|.mise.toml'
      paths: |
        ~/.cargo/git
        ~/.cargo/registry
      base-branch: 'main'

  - name: Install Rust with mise
    uses: 'nrwl/nx-cloud-workflows/v6/workflow-steps/install-mise/main.yaml'

  - name: Install dependencies
    uses: 'nrwl/nx-cloud-workflows/v6/workflow-steps/install-node-modules/main.yaml'

  - name: Fetch Rust dependencies
    script: cargo fetch --locked

launch-templates:
  linux-medium-polyglot:
    resource-class: 'docker_linux_amd64/medium'
    image: 'ubuntu22.04-node24.14-v1'
    init-steps: *common-init-steps

This way every agent in the pool knows about the JS and Rust toolchains and Nx can hand any task to each of them.

A run on this repository for example picks up 33 tasks and spreads Cargo builds, Vite builds and ESLint runs across the same two machines we defined. If we need more machines, we just change a number here, for example to 4:

.nx/ci-config.yaml
dte:
  distribute-on: 4 linux-medium-polyglot
...

Side-note: Publishing Rust crates

If you also want to release crates from a monorepo workspace, then you can configure Nx Release to do so.

Check out the full guide on Publish Rust crates.

Wrapping up

We're done! To recap, my goal was to show how you can have a polyglot Nx monorepo, mixing JavaScript and Rust (our own Nx repo is another example for that) and more importantly how you can delegate the setup largely to an agent.

Have a look at the repository and its build guide and play around with it yourself.

Learn more