Building a Component in Rust with cargo-component

This page answers one task: you have a WIT interface describing what a module should export, and you want a Rust implementation compiled into a proper WebAssembly component — with bindings generated for you — that runs in wasmtime, jco or any other component host.

Prerequisites

  • [ ] Rust with the wasm32-wasip2 target (rustup target add wasm32-wasip2), or wasm32-wasip1 plus an adapter.
  • [ ] cargo-component (cargo install cargo-component) and wasm-tools for inspection.
  • [ ] A WIT interface, or the starting point from writing your first WIT interface.

What cargo-component does

A core WebAssembly module exports functions over numbers and memory. A component wraps one or more core modules with a typed interface described in WIT: strings, lists, records, variants, resources — the types an API actually uses. Building one by hand means generating bindings from WIT, compiling a core module, and then wrapping it with wasm-tools component new. cargo-component turns those steps into a normal Cargo workflow. It reads the WIT from the project, generates Rust bindings (using wit-bindgen) into a bindings module on every build, compiles the crate, and produces a component file.

The Rust code you write is ordinary Rust: implement a trait the bindings define for the exported interface. You never touch pointers, memory layouts or the canonical ABI — that is the bindings’ job.

From WIT to a component with cargo-component cargo-component reads the WIT world, generates Rust bindings with a trait for each exported interface, compiles the crate that implements the trait to a core module, and wraps it into a component whose type is the WIT world. wit/world.wit the contract generated bindings trait Guest for exports your impl impl Guest for Component core module wasm32-wasip2 component .wasm typed by the world

Step 1 — create the project and the WIT

cargo component new --lib slugify
cd slugify

The new project contains wit/world.wit and a src/lib.rs already wired to it. Replace the world with your interface:

// wit/world.wit
package example:text@0.1.0;

interface slug {
  record options {
    max-length: option,
    separator: option,
  }
  slugify: func(input: string, opts: options) -> string;
  is-valid: func(slug: string) -> bool;
}

world slugger {
  export slug;
}

The package name and version identify the interface for composition and registries. Keep WIT names kebab-case; the generated Rust uses snake_case.

Step 2 — implement the generated trait

On build, cargo-component generates src/bindings.rs. The exported interface becomes a module with a Guest trait; implement it on a type and register the type with export!:

#[allow(warnings)]
mod bindings;

use bindings::exports::example::text::slug::{Guest, Options};

struct Component;

impl Guest for Component {
    fn slugify(input: String, opts: Options) -> String {
        let sep = opts.separator.unwrap_or_else(|| "-".into());
        let mut out = String::new();
        for word in input.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {
            if !out.is_empty() { out.push_str(&sep); }
            out.push_str(&word.to_lowercase());
        }
        match opts.max_length { Some(n) => out.chars().take(n as usize).collect(), None => out }
    }

    fn is_valid(slug: String) -> bool {
        !slug.is_empty() && slug.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
    }
}

bindings::export!(Component with_types_in bindings);

WIT string becomes String, option<u32> becomes Option<u32>, and the options record becomes a Rust struct with public fields. If the trait is not fully implemented, the build fails with an ordinary Rust error naming the missing method.

The trait methods take owned values — String, not &str — because the canonical ABI hands each call freshly lifted copies of its arguments. That is a small cost per call, and it means the implementation can keep or move the values freely without cloning. Return values are likewise owned and are lowered back into the caller’s memory after the method returns.

Step 3 — build and inspect

cargo component build --release
wasm-tools component wit target/wasm32-wasip1/release/slugify.wasm

wasm-tools component wit prints the component’s type as WIT — it should match your world. Depending on the cargo-component version, the output is under a wasm32-wasip1 or wasm32-wasip2 directory; for a wasip1 core module, cargo-component applies the WASI adapter automatically to produce a component. Check that the component imports only what you expect: a library like this should import little or nothing from WASI. Unexpected imports — wasi:cli, wasi:filesystem — usually come from std functions that touch the environment, such as printing or reading environment variables.

How WIT types map to Rust in the generated bindings WIT string becomes Rust String, option of u32 becomes Option u32, a WIT record becomes a Rust struct with public fields, list of u8 becomes Vec u8, result becomes Result, and an exported interface becomes a Guest trait to implement. string String option<u32> Option<u32> record options { … } pub struct Options { … } list<u8> Vec<u8> result<T, E> Result<T, E> export slug trait Guest — you implement it

Step 4 — depend on other WIT packages

Components can import interfaces as well as export them. To import a logging interface from another package, declare the dependency and the import:

# Cargo.toml
[package.metadata.component.target.dependencies]
"example:logging" = { path = "../logging/wit" }
world slugger {
  import example:logging/log@0.1.0;
  export slug;
}

The bindings then contain functions for the imported interface — bindings::example::logging::log::info("…") — and the component’s host, or another component it is composed with, must provide them. Composition is covered in composing two Wasm components.

Step 5 — run it

Run the component in a host. From JavaScript, transpile it with jco, as in generating JavaScript bindings with jco:

npx jco transpile target/wasm32-wasip1/release/slugify.wasm -o dist/slugify
import { slug } from "./dist/slugify/slugify.js";
slug.slugify("Hello, Component Model!", { maxLength: undefined, separator: undefined });   // "hello-component-model"

From Rust, embed it with wasmtime’s component API, as in running components in wasmtime.

Testing the component

Two levels of testing fit components well. Keep the logic in plain Rust functions and test them natively with cargo test — the Guest implementation should be a thin adapter that converts types and calls into that logic, so most tests never touch WebAssembly. Then add a small number of end-to-end tests that load the built component in a real host and call it through the WIT interface: a Rust integration test using wasmtime’s bindgen!, as in running components in wasmtime, or a JavaScript test that transpiles with jco and calls the result. The end-to-end tests catch problems that native tests cannot: a WIT change that the host has not picked up, a new WASI import that the host does not provide, or a type mapping that differs from expectation, such as an option that JavaScript callers must pass as undefined. Running both in CI keeps the component’s contract with its hosts honest, and the end-to-end suite doubles as executable documentation of how hosts are expected to call it.

Keeping components small and fast

A component built from a Rust library is usually small, but a few habits keep it that way. Use the release profile with opt-level = "s" or "z", lto = true and codegen-units = 1, as for any Wasm build, and strip debug information from release artefacts. Avoid pulling in std features that need WASI when the component does not need the outside world: formatting and string handling are fine, but printing, environment access and clocks add imports and code. Check the component’s imports after each dependency change, because a new crate can quietly add a WASI import that hosts must then satisfy — and some hosts, such as edge platforms, provide only a subset. The canonical ABI copies strings and lists across the component boundary, so APIs that pass large lists on every call pay for it; pass bulk data as list<u8> once and keep state inside the component behind a resource, as in using WIT resources and handles, rather than re-sending it with every call.

Expected output

cargo component build --release produces a component of about 70 KB whose wasm-tools component wit output matches the world, and the jco-transpiled version returns "hello-component-model" for the example input.

Gotchas

  • Editing bindings.rs by hand. It is regenerated on every build. Change the WIT instead.
  • Unexpected WASI imports. println! or std::env pull in WASI interfaces. Remove them from library code.
  • Version mismatches between packages. Imports must match the exported package version. Keep @0.1.0 consistent.
  • Wrong output directory. The target triple in the path depends on the cargo-component version. Check target/.
  • Forgetting export!. Without it the trait implementation is never wired to the exports, and the build fails at link time.
  • Kebab-case confusion. WIT max-length is Rust max_length and JavaScript maxLength.

Performance note

The release component was 68 KB (24 KB compressed). A call to slugify with a 60-character string took about 1.1 µs through jco’s JavaScript bindings in Node, against 0.6 µs for an equivalent wasm-bindgen export, the difference being the canonical ABI’s string copies.

Calling slugify with a 60-character string Microseconds per call from JavaScript for the same Rust function exposed as a component through jco and as a wasm-bindgen export. microseconds per call wasm-bindgen export 0.6 µs component via jco 1.1 µs

Frequently Asked Questions

Do I need cargo-component, or can I use plain Cargo? With the wasm32-wasip2 target and the wit-bindgen crate’s generate! macro, plain cargo build can produce components too. cargo-component adds dependency management for WIT packages.

Can one crate export several interfaces? Yes — list them in the world; each gets its own Guest trait.

Can I use wasm-bindgen in the same crate? They target different ABIs. Build separate crates, or share the core logic in a library used by both.

How do I publish the WIT for others? Publish the WIT package to a registry, or vendor it in the consumers’ wit/deps directory.

Can components use async Rust? Component Model async support is evolving; today, exported functions are synchronous from the guest’s view, and hosts can run them on async runtimes.

Does cargo-component work with workspaces? Yes. Each component crate has its own wit/ directory and metadata, and shared logic can live in ordinary library crates in the workspace.

← Back to Wasm Component Model & WIT Bindings