wasm-pack and Bundlers: Shipping Wasm to npm

This chapter covers wasm-pack, target formats, and how to integrate a Wasm module into a webpack or Vite build.

What wasm-pack Does

wasm-pack is one tool that runs Cargo, then wasm-bindgen, then builds an npm-compatible package. One command: build, transform, package.

wasm-pack build --target web

Output in pkg/:

pkg/
├── package.json         npm metadata
├── my_crate.js          JS loader
├── my_crate_bg.wasm     the Wasm
├── my_crate.d.ts        TypeScript defs
└── README.md

You can npm install that directory locally, or publish it:

wasm-pack publish

Skip wasm-pack if: you're not publishing to npm and don't need the packaging. cargo build plus wasm-bindgen is enough.

Use wasm-pack if: you want an npm package, or you want the standard npm workflow without hand-assembling it.

Target Modes

--target controls the JS output format.

--target web

For use in <script type="module"> directly.

wasm-pack build --target web
<script type="module">
  import init, { greet } from "./pkg/my_crate.js";
  await init();
  greet("world");
</script>

No bundler needed. Works on a plain static server.

--target bundler

For webpack, Vite, Parcel, Rollup.

wasm-pack build --target bundler
import { greet } from "my_crate";   // bundler handles Wasm init
greet("world");

The bundler is expected to load the .wasm file automatically.

--target nodejs

For Node.js CommonJS.

wasm-pack build --target nodejs
const { greet } = require("my_crate");
console.log(greet("world"));

Uses Node's WebAssembly API and filesystem reads.

--target no-modules

Global script tag (no module system).

wasm-pack build --target no-modules

Rarely needed now; use it if you're stuck on a very old setup.

Vite Integration

Vite is the friendliest bundler for Wasm. With vite-plugin-wasm:

npm install vite-plugin-wasm --save-dev
// vite.config.js
import { defineConfig } from "vite";
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";

export default defineConfig({
  plugins: [wasm(), topLevelAwait()],
});

topLevelAwait lets you write await init() at the module level. Without it, you need to wrap in an async function.

Build your Rust crate with --target bundler and npm install ../path/to/pkg (or publish and install from npm). Then:

import { greet } from "my_crate";

console.log(greet("Ada"));

Vite handles fetching the .wasm, instantiating it, exposing the exports.

For development, Vite's HMR plays nicely with the Rust rebuild loop. After wasm-pack build, Vite picks up the change and reloads.

Webpack 5 Integration

Webpack 5 supports Wasm natively.

webpack.config.js:

module.exports = {
  experiments: {
    asyncWebAssembly: true,
  },
  output: {
    webassemblyModuleFilename: "[hash].wasm",
  },
};

--target bundler output works directly:

import { greet } from "my_crate";

async function run() {
  console.log(greet("Ada"));
}
run();

The import resolves the npm package; webpack emits the .wasm as an async module.

The wasm-pack-plugin

For a fully integrated dev loop where webpack watches your Rust source:

npm install @wasm-tool/wasm-pack-plugin --save-dev
// webpack.config.js
const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin");
const path = require("path");

module.exports = {
  experiments: { asyncWebAssembly: true },
  plugins: [
    new WasmPackPlugin({
      crateDirectory: path.resolve(__dirname, "./crate"),
    }),
  ],
};

Webpack rebuilds the Rust crate on change. One-command development.

React / Vue / Svelte

Wasm modules integrate with any frontend framework. The pattern is always the same:

  1. Lazy-load the Wasm module.
  2. Wait for initialization.
  3. Expose the exports through a context or a state slot.

React example:

import { useEffect, useState } from "react";

function useWasm() {
  const [wasm, setWasm] = useState(null);

  useEffect(() => {
    let cancelled = false;
    import("my_crate").then((mod) => {
      if (!cancelled) setWasm(mod);
    });
    return () => { cancelled = true; };
  }, []);

  return wasm;
}

function App() {
  const wasm = useWasm();
  if (!wasm) return <div>Loading...</div>;
  return <div>{wasm.greet("Ada")}</div>;
}

Wrap in a provider if multiple components need access.

SSR and Server Components

Wasm in a server-rendered app is tricky. Options:

  • Client-only. Load inside a useEffect; nothing runs during SSR.
  • Node-side Wasm. Use --target nodejs and import the same module in server code.
  • Split packages. One wasm-pack build for browser, another for Node.

Most frameworks (Next.js, SvelteKit, Nuxt) handle the first case out of the box if you wrap in typeof window !== "undefined" guards or use dynamic imports.

Publishing to npm

Once your pkg/ looks good:

wasm-pack publish

Under the hood, this runs npm publish in pkg/. Needs an npm account and npm login.

Versioning: bump in Cargo.toml (for the crate version) and let wasm-pack generate package.json from it. Or edit the generated package.json by hand.

TypeScript Support

wasm-pack emits .d.ts files. TypeScript users get autocompletion and type-checking for free.

import { greet, Counter } from "my_crate";

const name: string = greet("Ada");    // string, typed
const c: Counter = new Counter();     // Counter class, typed

The types come from your Rust signatures. #[wasm_bindgen] pub fn greet(name: &str) -> String becomes greet(name: string): string.

Common Issues

"Asynchronous WebAssembly modules must be in an asynchronous module." Webpack without asyncWebAssembly: true. Add it.

Top-level await errors. Vite needs vite-plugin-top-level-await, Webpack needs experiments: { topLevelAwait: true }.

import.meta not supported. Old bundler. Upgrade or use a different target mode.

Large bundle sizes. The Wasm binary is part of the bundle. wasm-opt -Oz and the size optimizations from Chapter 4 apply. Check webpack-bundle-analyzer or Vite's rollup-plugin-visualizer to see what's big.

Slow dev builds. Rust compiles slowly. For development, turn off lto and use opt-level = 1 in a [profile.dev] section. Production builds keep the optimization.

A Minimal Working Example

Full setup, start to finish.

Cargo.toml:

[package]
name = "greet"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

[profile.release]
opt-level = "z"
lto = true

src/lib.rs:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello, {name}!")
}

Build:

wasm-pack build --target bundler

In a Vite project:

npm install ../greet/pkg

src/main.js:

import { greet } from "greet";

document.body.innerText = greet("Ada");

vite.config.js:

import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";

export default {
  plugins: [wasm(), topLevelAwait()],
};
npm run dev

Open browser, see "Hello, Ada!". That's a full Rust-to-browser Wasm pipeline with a real bundler.

Next Steps

Continue to 08-wasi.md to run Wasm outside the browser.