Compiling Rust to Wasm with cargo component, A Step by Step Tutorial
TL;DR — cargo-component 0.18 is the cleanest path from Rust source to a WASI 0.2 component. Author the WIT, run
cargo component build, load the artifact in a wasmtime host through a generatedLinker. Three commands and ~60 lines of code per side.
If you tried to compile Rust to a Wasm component in 2023, you spent more time fighting bindings generators than writing the code. That story changed. cargo-component 0.18 hides almost all of the wit-bindgen ceremony, the wasm32-wasip2 target is upstream, and Wasmtime 28’s component API is stable. The whole pipeline finally feels normal.
This tutorial walks the full loop. We’ll build a small text-processing component that exposes a typed interface, then call it from a Rust host. Nothing toy-shaped. By the end you’ll have something you could lift into a real plugin system. If you want context on the wider runtime landscape before diving in, see the server-side Wasm survey.
I’m assuming you know Rust at a working level and have at least seen a Cargo.toml. I’m not assuming anything about Wasm.
Toolchain Setup
We’ll pin everything to known-good February 2025 versions. Future you, debugging a flaky build six months from now, will thank present you.
Step 1, Install Rust and the Wasm Target
rustup install 1.84.0
rustup default 1.84.0
rustup target add wasm32-wasip2
wasm32-wasip2 is the target for WASI Preview 2 components. The old wasm32-wasi is Preview 1 and is being phased out. Don’t start a new project on it.
Step 2, Install cargo-component and wasm-tools
cargo install cargo-component --version 0.18.0 --locked
cargo install wasm-tools --version 1.221.0 --locked
cargo install wasmtime-cli --version 28.0.0 --locked
The --locked flag is not optional. cargo-component’s transitive dep graph moves quickly. Lock or weep.
Step 3, Verify
cargo component --version
# cargo-component-component 0.18.0
wasm-tools --version
# wasm-tools 1.221.0
wasmtime --version
# wasmtime-cli 28.0.0
Designing the Component
We’re going to build a text-tools component that exposes three operations: word count, slugification, and a simple Caesar cipher. Boring on purpose. The point is the shape of the contract.
+-------------------+ +-------------------+
| Rust Host | | text-tools.wasm |
| (wasmtime 28) | WIT calls | (cargo-component)|
| +--------------> |
| - load .wasm | | exports: |
| - link WASI 0.2 | | word-count |
| - call exports | | slugify |
| <--------------+ caesar |
+-------------------+ results +-------------------+
Step 1, Scaffold the Component
cargo component new --lib text-tools
cd text-tools
cargo-component creates wit/world.wit for you. Replace it with our contract:
package amal:text-tools@0.1.0;
interface ops {
record stats {
words: u32,
chars: u32,
lines: u32,
}
variant cipher-error {
invalid-shift,
empty-input,
}
word-count: func(input: string) -> stats;
slugify: func(input: string) -> string;
caesar: func(input: string, shift: s32) -> result<string, cipher-error>;
}
world text-tools {
export ops;
}
A few things worth flagging. result<T, E> is the canonical fallible return shape. Use it instead of sentinel values. variant gives you sum types across the language boundary, which Rust enums map onto cleanly. record is a named struct. The WIT syntax is intentionally narrow, so you’re not going to overengineer it.
Step 2, Implement the Exports
Edit src/lib.rs:
#[allow(warnings)]
mod bindings;
use bindings::exports::amal::text_tools::ops::{
CipherError, Guest, Stats,
};
struct Component;
impl Guest for Component {
fn word_count(input: String) -> Stats {
let words = input.split_whitespace().count() as u32;
let chars = input.chars().count() as u32;
let lines = if input.is_empty() {
0
} else {
input.lines().count() as u32
};
Stats { words, chars, lines }
}
fn slugify(input: String) -> String {
input
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
fn caesar(input: String, shift: i32) -> Result<String, CipherError> {
if input.is_empty() {
return Err(CipherError::EmptyInput);
}
if shift.unsigned_abs() > 25 {
return Err(CipherError::InvalidShift);
}
let shift = shift.rem_euclid(26) as u8;
let out: String = input
.chars()
.map(|c| match c {
'a'..='z' => (((c as u8 - b'a' + shift) % 26) + b'a') as char,
'A'..='Z' => (((c as u8 - b'A' + shift) % 26) + b'A') as char,
other => other,
})
.collect();
Ok(out)
}
}
bindings::export!(Component with_types_in bindings);
The bindings module is auto-generated by cargo-component from your WIT. You never edit it. WIT kebab-case names become snake_case in Rust, and variants become PascalCase.
Step 3, Build the Component
cargo component build --release
ls -lh target/wasm32-wasip2/release/text_tools.wasm
You should see a .wasm artifact around 100-200 KB. To confirm it’s actually a component and not a bare core module:
wasm-tools component wit target/wasm32-wasip2/release/text_tools.wasm
If that prints WIT, you have a component. If it errors with “not a component”, you accidentally ran cargo build instead of cargo component build.
Step 4, Smoke Test with wasmtime CLI
wasmtime run --invoke 'word-count("hello there world")' \
target/wasm32-wasip2/release/text_tools.wasm
# {words: 3, chars: 17, lines: 1}
wasmtime run --invoke 'slugify("Hello, World! Take 2")' \
target/wasm32-wasip2/release/text_tools.wasm
# "hello-world-take-2"
That’s a fully typed call across the component boundary, no host code yet.
Calling the Component from a Rust Host
Now we’ll embed wasmtime in a host binary and call our component programmatically. This is the pattern you use for plugin systems, function-as-a-service runners, or any “load user code at runtime” use case.
Step 1, Create the Host Project
cd ..
cargo new --bin text-host
cd text-host
Edit Cargo.toml:
[package]
name = "text-host"
version = "0.1.0"
edition = "2021"
[dependencies]
wasmtime = { version = "28.0", features = ["component-model"] }
wasmtime-wasi = "28.0"
anyhow = "1"
tokio = { version = "1", features = ["full"] }
Step 2, Generate Host-Side Bindings
We could hand-roll the typed calls, but wasmtime::component::bindgen! does it from the WIT file. Copy wit/world.wit from the component into text-host/wit/world.wit.
Step 3, Write the Host
src/main.rs:
use anyhow::Result;
use wasmtime::component::{Component, Linker, ResourceTable};
use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiView};
wasmtime::component::bindgen!({
path: "wit/world.wit",
world: "text-tools",
async: false,
});
struct HostState {
table: ResourceTable,
wasi: WasiCtx,
}
impl WasiView for HostState {
fn table(&mut self) -> &mut ResourceTable { &mut self.table }
fn ctx(&mut self) -> &mut WasiCtx { &mut self.wasi }
}
fn main() -> Result<()> {
let mut config = Config::new();
config.wasm_component_model(true);
let engine = Engine::new(&config)?;
let component = Component::from_file(
&engine,
"../text-tools/target/wasm32-wasip2/release/text_tools.wasm",
)?;
let mut linker = Linker::<HostState>::new(&engine);
wasmtime_wasi::add_to_linker_sync(&mut linker)?;
let state = HostState {
table: ResourceTable::new(),
wasi: WasiCtxBuilder::new().inherit_stdio().build(),
};
let mut store = Store::new(&engine, state);
let bindings = TextTools::instantiate(&mut store, &component, &linker)?;
let ops = bindings.amal_text_tools_ops();
let stats = ops.call_word_count(&mut store, "the quick brown fox")?;
println!("word_count -> {:?}", stats);
let slug = ops.call_slugify(&mut store, "Hello, World! Take 2")?;
println!("slugify -> {}", slug);
match ops.call_caesar(&mut store, "Attack at dawn", 3)? {
Ok(s) => println!("caesar -> {}", s),
Err(e) => println!("caesar err -> {:?}", e),
}
Ok(())
}
Step 4, Run
cargo run --release
# word_count -> Stats { words: 4, chars: 19, lines: 1 }
# slugify -> hello-world-take-2
# caesar -> Dwwdfn dw gdzq
Three typed calls across the component boundary. No JSON serialization. No FFI. The host and guest could be different languages and this would still work.
Understanding the Generated Bindings
It’s worth taking a few minutes to understand what cargo-component actually generates, because when something goes wrong, you’ll be reading this code. The bindings module is produced at build time from your WIT and placed under target/wasm32-wasip2/release/build/.../out/bindings.rs. It’s a fully-typed Rust module with structs for every WIT record, enums for every variant, and traits for every interface.
The translation rules are mechanical. WIT kebab-case becomes Rust snake_case for functions and fields. WIT type names become Rust PascalCase. A WIT option<T> becomes Rust Option<T>. A WIT result<T, E> becomes Rust Result<T, E>. A WIT list<T> becomes Vec<T>. A WIT string becomes Rust String on the guest side (owned) or &str on the host call (borrowed, depending on bindgen mode).
The export is wired up by the bindings::export!(Component with_types_in bindings); macro. That macro generates the underlying cabi_realloc and the entry-point shims that the component model ABI expects. You almost never read this code, but knowing it exists helps when you see “unresolved import cabi_realloc” errors during embedding tests.
The host side of bindgen is more interesting because you actually look at the generated types. wasmtime::component::bindgen! produces a struct named after your WIT world (e.g., TextTools) with instantiate and instantiate_async constructors, plus an accessor for each exported interface (e.g., text_tools.amal_text_tools_ops()). Each interface has methods named call_<function> for invoking guest functions. There’s no magic. Read the generated code if you’re stuck.
Production-Shaping the Component
A toy component is easy. A production component is the same shape with five more concerns. Touch on them now so you’re not surprised later.
Size. Run wasm-opt -Os from binaryen as a release post-step. Strip with wasm-tools strip. Both shave 10-40% off without changing behavior.
Determinism. Forbid std::time::SystemTime and rand::thread_rng in component code unless you’ve thought about it. Both pull non-determinism in through WASI clocks and random, which you may want, but not by accident.
Resource limits. On the host side, set Store::limiter with a StoreLimits that caps memory and tables. Untrusted components without limits will eat your RAM.
Component versioning. Embed your version in the WIT package (@0.1.0) and validate it on load. wasm-tools component wit lets you read the WIT out of any compiled component, which is great for runtime version negotiation.
Logging. WASI 0.2 logging is via stdio for now. For structured logs, define your own interface log in WIT and implement it on the host side.
Common Pitfalls
Forgetting to add wit/world.wit to the host crate. The host’s bindgen! macro reads from a file path relative to the host crate. People assume it pulls from the component crate. It doesn’t. Copy the WIT, or use a shared workspace path.
Mismatched WIT versions between host and guest. If you bump the component’s WIT but forget to update the host’s copy, instantiate will fail with a confusing “missing export” error. Make this a CI check.
Building with cargo build instead of cargo component build. This produces a core module, not a component. The error at load time is “not a component” or “unknown section.” Always go through cargo-component.
Async confusion. bindgen! defaults to async if you don’t specify. Set async: false explicitly for synchronous components and async: true only if your host runtime can poll futures. Mixing modes within one binding generates code that compiles but deadlocks.
Troubleshooting
failed to find linker errors at instantiate. Your component imports a WASI interface you didn’t link. Run wasm-tools component wit your.wasm and look at the import lines. Add the matching add_to_linker calls. wasmtime_wasi::add_to_linker_sync covers most of the standard surface.
cannot find type Stats in module bindings. The bindgen output path follows your WIT package and interface names. If you renamed something, the Rust path changed. Read the generated bindings.rs (under target/.../build/) to confirm.
Surprisingly large binary. Likely pulling in std formatting machinery you don’t need. Try panic = "abort" in Cargo.toml’s [profile.release], strip = true, and opt-level = "z". For real minimization, go no_std, but that’s a much bigger project.
Iterating on the WIT
Real projects evolve the WIT contract. cargo-component handles this gracefully if you follow a couple of habits. First, store your WIT files in a directory the project tree (not in a git submodule of an upstream) so you can edit and version them like any other source. Second, use WIT versioning from day one. Even if you’re at 0.0.1, having a version string in the package declaration means you can later use wac (the WIT Adapter Composer) or wasm-tools compose against versioned WIT and not have to refactor.
When you change WIT, run cargo component check before cargo component build. The check pass catches binding mismatches faster than a full build. If you added a new export, you’ll see a clear “trait method not implemented” error pointing at your Guest impl. If you removed an export, you’ll see dead-code warnings. If you renamed something, both ends light up.
For larger projects with multiple components sharing types, define a shared WIT package (something like amal:common) that exports the common types as interfaces. Other components import from it. cargo-component resolves the imports against wit/deps/ or against a registry if you configure one. The registry story is still maturing in early 2025; for now most teams check shared WIT in via git submodules or path dependencies. Not elegant, works fine.
Wrapping Up
You now have a working Rust to component to host pipeline pinned to February 2025 versions. The same pattern scales up. Swap text-tools for a billing-rule engine, a CSS extractor, a policy evaluator, or any other “user-supplied code” surface, and the contract shape (WIT in, WIT out, typed both sides) is identical. The official component model docs at component-model.bytecodealliance.org cover the language-mapping rules in more depth when you outgrow the basics. Next article in the series, we’ll go deeper into the wasmtime host side and look at resource limits, fuel metering, and async.