Structuring a Monorepo with Rust Wasm and a JS App

This page answers one task: organise a repository that contains both Rust crates compiled to WebAssembly and the JavaScript or TypeScript application that uses them, so that a change on either side is built, tested and shipped together without publishing anything to a registry in between.

Prerequisites

  • [ ] Rust with the wasm32-unknown-unknown target and wasm-pack 0.12+.
  • [ ] Node 20+ and pnpm 9 (npm and Yarn workspaces work similarly; the commands differ).
  • [ ] An existing crate and an existing web app, or the willingness to create both from scratch.

The shape that works

The difficulty with mixing the two ecosystems is that each has its own idea of a workspace. Cargo wants a Cargo.toml at the root listing member crates and a single target/ directory. pnpm wants a pnpm-workspace.yaml listing member packages and a single node_modules store. They do not know about each other, and the bridge between them — the package wasm-pack generates — is a build output, not source.

The layout that keeps both tools happy puts Rust crates under one directory, JavaScript packages under another, and treats each crate’s generated pkg/ directory as a workspace package in its own right. The web app then depends on that package through the workspace protocol, exactly as it would depend on any other local package.

Repository layout for a Rust and JavaScript monorepo The repository root holds both workspace manifests. Rust crates live under crates, each producing a pkg directory with wasm-pack. JavaScript apps live under apps and depend on those pkg directories through the workspace protocol. repository root Cargo.toml (workspace), pnpm-workspace.yaml, justfile, rust-toolchain.toml crates/geometry/ Rust crate: src/, Cargo.toml with crate-type cdylib crates/geometry/pkg/ wasm-pack output: package.json, .wasm, .js, .d.ts — gitignored apps/web/ Vite app: package.json depends on geometry via workspace:* target/ and node_modules/ one build directory per ecosystem, shared by all members

Step 1 — the two workspace manifests

# Cargo.toml (root)
[workspace]
resolver = "2"
members = ["crates/*"]

[workspace.dependencies]
wasm-bindgen = "=0.2.93"
serde = { version = "1", features = ["derive"] }

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "crates/*/pkg"

Putting crates/*/pkg in the pnpm workspace is the key move. Once wasm-pack has generated the package, pnpm links it into the app’s node_modules like any other workspace package — no npm link, no publishing, no path-based dependency that breaks on another machine.

Shared Rust dependency versions live in [workspace.dependencies], and member crates refer to them with wasm-bindgen = { workspace = true }. That guarantees every crate uses the same wasm-bindgen, which matters because two crates linked into one page with different versions produce two incompatible sets of glue.

Step 2 — give the generated package a stable name

wasm-pack derives the npm package name from the crate name. Set a scope so it cannot collide with a public package and so imports read clearly:

wasm-pack build crates/geometry --release --target bundler --scope acme --out-dir pkg

That produces crates/geometry/pkg/package.json with "name": "@acme/geometry". The app declares the dependency through the workspace protocol:

{
  "name": "@acme/web",
  "private": true,
  "dependencies": {
    "@acme/geometry": "workspace:*"
  }
}
// apps/web/src/area.ts
import { polygon_area } from "@acme/geometry";

export function area(points: Float64Array): number {
  return polygon_area(points);
}

Use --target bundler when the app is built by Vite, webpack or Rollup; the generated package then imports the .wasm as a module and lets the bundler handle loading. The trade-offs between targets are covered in best practices for wasm-pack configuration.

Step 3 — build in dependency order

The app cannot build until the package exists, and pnpm does not know the package comes from a Rust build. Make the order explicit with a root script, or with a just recipe as in driving Wasm builds with a justfile:

{
  "private": true,
  "scripts": {
    "build:wasm": "wasm-pack build crates/geometry --release --target bundler --scope acme --out-dir pkg",
    "build:web": "pnpm --filter @acme/web build",
    "build": "pnpm build:wasm && pnpm install --offline && pnpm build:web"
  }
}

The pnpm install --offline between the two steps is easy to miss and important on a clean checkout: the first install ran before pkg/ existed, so pnpm could not link it. A second install after the Rust build creates the link. It is fast because nothing is downloaded.

Build order on a clean checkout The Rust crate is compiled to a package first, then pnpm install links the new package into the app's node_modules, then the app is built and bundles the module. Skipping the second install leaves the app unable to resolve the package. pnpm install links apps; pkg/ missing wasm-pack build creates crates/*/pkg pnpm install --offline links @acme/geometry vite build bundles the .wasm On later builds the link already exists, so only the Rust build and the app build need to run.

Step 4 — watch both sides during development

For day-to-day work you want an edit in Rust to show up in the browser without manual steps. Run a Rust watcher that rebuilds the package, and let the app’s dev server notice the changed files:

# terminal 1 — rebuild the package on Rust changes
cargo watch -w crates/geometry/src -s \
  "wasm-pack build crates/geometry --dev --target bundler --scope acme --out-dir pkg"

# terminal 2 — the app's dev server
pnpm --filter @acme/web dev

Vite does not watch node_modules by default and pre-bundles dependencies, so a workspace package that changes underneath it needs two settings:

// apps/web/vite.config.ts
export default {
  optimizeDeps: { exclude: ["@acme/geometry"] },
  server: { watch: { ignored: ["!**/node_modules/@acme/**"] } },
};

The full setup, including incremental-build timings, is in hot reloading a Rust Wasm crate during development.

Where shared types and constants should live

A monorepo invites the question of which side owns the data model. A geometry crate and a web app both need to agree on what a point, a polygon or an error code looks like, and the worst outcome is two definitions that drift apart silently. Make one side the source of truth and generate the other.

When the Rust crate is the computational core, let it own the types and let wasm-bindgen’s generated .d.ts carry them to TypeScript. The app then imports types from @acme/geometry rather than redeclaring them, and a renamed field becomes a compile error in the app the moment the package is rebuilt. When the data model belongs to a backend API instead, generate both the Rust structs and the TypeScript types from the same schema — OpenAPI, JSON Schema or a WIT file — and treat the generated code in both places as build output.

Constants are a smaller version of the same problem. Limits like a maximum polygon size or a buffer length are often needed on both sides: the Rust code enforces them and the UI uses them to validate input before calling in. Export them from Rust as functions or as #[wasm_bindgen] constants, and the app reads the real value instead of a copy that someone forgets to update.

Step 5 — CI that builds only what changed

In a monorepo, every push should not rebuild every crate. Use path filters so Rust work runs when Rust changed, and pass the built package to the app job as an artifact:

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      rust: ${{ steps.f.outputs.rust }}
    steps:
      - uses: actions/checkout@v4
      - id: f
        uses: dorny/paths-filter@v3
        with:
          filters: |
            rust: ["crates/**", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml"]

  wasm:
    needs: changes
    if: needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: Swatinem/rust-cache@v2
      - run: pnpm build:wasm
      - uses: actions/upload-artifact@v4
        with: { name: geometry-pkg, path: crates/geometry/pkg }

When Rust did not change, the app job can download the package from the latest successful build on the main branch instead of compiling it. That keeps JavaScript-only changes — the majority in most product teams — fast.

Expected output

$ pnpm build
> wasm-pack build crates/geometry --release --target bundler --scope acme --out-dir pkg
[INFO]: ✨   Done in 18.42s
[INFO]: 📦   Your wasm pkg is ready to publish at crates/geometry/pkg.
> pnpm install --offline
Scope: all 3 workspace projects
Done in 412ms
> pnpm --filter @acme/web build
vite v5.4.8 building for production...
dist/assets/geometry_bg-4c1a9e.wasm   38.21 kB │ gzip: 17.90 kB
dist/assets/index-b72f0d.js            61.44 kB │ gzip: 21.03 kB
✓ built in 2.71s

Gotchas

  • Failed to resolve import "@acme/geometry". The package was built after the last install. Run pnpm install --offline after wasm-pack build.
  • The app shows old behaviour after a Rust change. Vite’s dependency pre-bundling cached the old package. Exclude it in optimizeDeps, or restart with --force.
  • pkg/ committed by accident. wasm-pack writes a .gitignore inside pkg/ containing *, but some tools delete it. Add crates/*/pkg/ to the root .gitignore as well.
  • Two crates, two copies of wasm-bindgen glue. Each crate compiled separately is a separate module with its own glue and its own memory. If they need to share data, merge them into one crate that re-exports both.

Performance note

Splitting one large crate into two separately compiled Wasm packages cost 31 KB on the page in this project — each package carried its own allocator, panic machinery and wasm-bindgen runtime. Separate packages are worth it when they load on different pages; when both always load together, one crate with two modules inside it produces a smaller single binary.

One crate or several Wasm packages Merging crates into one module avoids duplicated runtime code when they always load together; separate packages pay off when they load on different pages. one crate, one module one allocator, one panic handler, one glue file smallest total when everything loads together one memory, so data is shared without copying features used on the same page several packages each carries its own runtime overhead each page downloads only what it uses separate memories; sharing data means copying features used on different pages

Frequently Asked Questions

Should the generated package be published to a registry instead? Only if other repositories consume it. Inside one repository the workspace link is faster and keeps Rust and app changes in one commit; publishing is covered in publishing a Wasm package to npm.

Can TypeScript types flow from Rust to the app? Yes — wasm-pack generates a .d.ts file in the package, and the app picks it up through the workspace link like any other dependency’s types. Customising those types is covered in customising TypeScript output from wasm-bindgen.

Does Turborepo or Nx help? They add caching and task graphs across packages. They can run the Rust build as a task with declared outputs (crates/*/pkg/**), which gives you skip-if-unchanged for free. For two or three packages, plain scripts are enough.

← Back to Cross-Platform Build Automation