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:
- Lazy-load the Wasm module.
- Wait for initialization.
- 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 nodejsand 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.