Building a Rust Wasm App with Trunk
This page answers one task: build and serve a web application written in Rust — with Leptos, Yew, Dioxus or plain
web-sys — using Trunk, so there is no JavaScript build tooling in the project at all.
Prerequisites
- [ ] Rust with the
wasm32-unknown-unknowntarget. - [ ] Trunk 0.21+ (
cargo install trunk --locked, or a release binary viacargo binstall trunk). - [ ] A binary crate (
src/main.rs) that starts the app, not a library crate.
What Trunk is for
Most Rust-to-WebAssembly tooling assumes a JavaScript project on the other side: wasm-pack produces an npm package, and a bundler such as Vite assembles the page. That is the right shape when Rust is one component of a larger JavaScript application. It is unnecessary overhead when the whole front end is Rust — a Leptos or Yew app where JavaScript only exists as generated glue.
Trunk fills that gap. It is a bundler for Rust front ends, driven by an index.html file rather than a JavaScript
entry point. Links in that HTML with a data-trunk attribute tell Trunk what to build and copy: the Rust crate,
stylesheets, images, static directories. Trunk runs cargo and wasm-bindgen, processes the assets, rewrites the HTML
to point at hashed output files, and serves the result with automatic rebuild and reload on change. There is no
package.json, no node_modules, and no JavaScript configuration file.
Step 1 — the HTML entry point
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Polygon Lab</title>
<link data-trunk rel="rust" data-wasm-opt="z" data-bin="polygon-lab" />
<link data-trunk rel="css" href="styles/main.css" />
<link data-trunk rel="copy-dir" href="assets/" />
<link data-trunk rel="icon" href="assets/favicon.svg" />
</head>
<body></body>
</html>
The rel="rust" link is the important one: it tells Trunk to build the crate in the current directory, run
wasm-bindgen with the web target, and inject a small script that loads the result. data-wasm-opt="z" runs
wasm-opt -Oz in release builds only. data-bin names the binary when the crate has more than one.
Step 2 — the Rust entry point
A Trunk app is a binary crate whose main function mounts the UI. With plain web-sys:
// src/main.rs
use wasm_bindgen::prelude::*;
fn main() {
console_error_panic_hook::set_once();
let document = web_sys::window().unwrap().document().unwrap();
let body = document.body().unwrap();
let p = document.create_element("p").unwrap();
p.set_text_content(Some("Hello from Rust, built by Trunk"));
body.append_child(&p).unwrap();
}
# Cargo.toml
[package]
name = "polygon-lab"
version = "0.1.0"
edition = "2021"
[dependencies]
wasm-bindgen = "=0.2.93"
console_error_panic_hook = "0.1"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "Node"] }
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
With a framework, main calls its mount function instead — leptos::mount_to_body(App) or
yew::Renderer::<App>::new().render() — and everything else stays the same. The framework choice itself is discussed
in comparing Yew, Leptos and Dioxus.
Step 3 — serve with automatic reload
trunk serve --open
INFO 🚀 Starting trunk 0.21.1
INFO 📦 starting build
INFO compiling wasm
INFO calling wasm-bindgen for polygon-lab
INFO applying new distribution
INFO ✅ success
INFO 📡 serving static assets at -> /
INFO 📡 server listening at:
INFO 🏠 http://127.0.0.1:8080/
Trunk watches the crate and the assets. A Rust edit triggers cargo and wasm-bindgen; a CSS edit triggers only the
asset pipeline. Either way the page reloads automatically through a small WebSocket client Trunk injects in serve
mode. For cross-origin isolation or other headers, configure them in Trunk.toml:
# Trunk.toml
[serve]
address = "127.0.0.1"
port = 8080
[serve.headers]
"Cross-Origin-Opener-Policy" = "same-origin"
"Cross-Origin-Embedder-Policy" = "require-corp"
Step 4 — build for release
trunk build --release --public-url /lab/
dist/
index.html
polygon-lab-8c2a51f3e0b7d9a4.js
polygon-lab-8c2a51f3e0b7d9a4_bg.wasm
main-1f0e93c2d6a7b845.css
assets/
Every generated file has a content hash in its name, and index.html is rewritten to reference them. That makes the
hashed files safe to cache forever while index.html itself is served with a short cache lifetime — exactly the policy
in setting Cache-Control headers for Wasm.
--public-url sets the base path, needed when the app is not served from the domain root.
Deploying the dist directory
The output of a release build is a directory of static files, which means it deploys anywhere static files deploy: an object store behind a CDN, a static-site host, a container running nginx, or an edge platform’s asset binding. There is no server-side code to run, and no build step on the host. Three details decide whether the deployed app behaves like the local one.
First, the host must serve the .wasm file with Content-Type: application/wasm. Most static hosts do; object
stores sometimes do not, and then the module falls back to slower ArrayBuffer compilation or fails outright. Upload
with an explicit content type if your tooling allows it.
Second, single-page apps with client-side routing need every unknown path to return index.html, so a deep link
such as /lab/settings loads the app instead of a 404. That is a host configuration — a rewrite or fallback rule —
not something Trunk can produce.
Third, the cache policy should follow the hashes. Hashed files can be cached for a year with immutable, because a
new build produces new names. index.html must be revalidated on every visit, or users will keep loading an old HTML
file that points at old hashed files — which still exist in their cache, so the app works, but never updates.
Getting those three right once, in the host’s configuration, means every later deploy is just a copy of dist/.
Teams that deploy through CI typically run trunk build --release in the pipeline, then upload the directory with a
command that sets content types and cache headers per file pattern, and finally invalidate only index.html at the CDN.
Step 5 — add hooks for anything Trunk does not do
Trunk’s asset pipeline covers the common cases. For anything else — generating a service worker, precompressing outputs, running a Tailwind build — use hooks, which run commands at defined stages:
# Trunk.toml
[[hooks]]
stage = "post_build"
command = "sh"
command_arguments = ["-c", "find $TRUNK_STAGING_DIR -name '*.wasm' -exec brotli -q 11 -k {} \\;"]
post_build hooks run against the staging directory before it is moved into dist, so precompressed files land
alongside the originals with matching hashed names. Environment variables like TRUNK_PROFILE tell the hook whether
this is a release build.
Expected output
After trunk build --release, the module should be small and the page should load it once:
ls -la dist/*.wasm && wc -c dist/index.html
-rw-r--r-- 1 dev staff 96211 dist/polygon-lab-8c2a51f3e0b7d9a4_bg.wasm
812 dist/index.html
In the browser, the Network panel shows the HTML, one JavaScript file, one .wasm file with type wasm, and the CSS.
Gotchas
error: no binaries available. The crate is a library. Trunk needs a binary with amainfunction; addsrc/main.rs, or pointdata-binat the right one.- The page is blank and the console shows a panic.
mainpanicked before rendering anything, often on anunwrap()of a missing DOM element.console_error_panic_hookturns that into a readable message; install it first thing inmain. - Assets 404 after deploying to a sub-path. The build used the default public URL of
/. Pass--public-url /your/path/. - wasm-opt did not run.
data-wasm-optapplies only to--releasebuilds, and Trunk needs to find or download Binaryen. Check the build log for the wasm-opt step. - Two different wasm-bindgen versions. Trunk downloads a CLI matching the crate version automatically; a global
wasm-bindgenon the PATH at another version can confuse it. Pin the crate and let Trunk manage the CLI.
Performance note
For the example app, a release build with data-wasm-opt="z" and the profile above produced a 96 KB module (41 KB
Brotli-compressed). Without wasm-opt, it was 158 KB; with the default dev profile, 2.1 MB. The incremental rebuild in
trunk serve after a one-line edit took 1.9 s, most of it cargo.
Frequently Asked Questions
Should I use Trunk or Vite for a Rust app? Trunk when the front end is entirely Rust. Vite with a wasm-pack package when Rust is a component inside a JavaScript or TypeScript application. Mixing the two in one project is rarely worth it.
Does Trunk support server-side rendering?
No — Trunk builds client-side bundles. Frameworks with SSR, such as Leptos, use their own tooling (cargo-leptos) for
the server half; see server-side rendering with Leptos.
Can I use npm packages with Trunk?
Not through a package manager. You can copy a prebuilt JavaScript file into assets/ and load it with a script tag,
or import it from Rust with #[wasm_bindgen(module = "/assets/lib.js")].
Can Trunk build web workers?
Yes. A second rel="rust" link with data-type="worker" builds a separate binary as a worker script, which the main
app can start with the generated file name. That is the usual way to keep heavy computation off the UI thread in an
all-Rust app.
How do I add Tailwind CSS?
Trunk has a rel="tailwind-css" asset type that runs the Tailwind standalone CLI, or you can use a pre-build hook.
Related
- Building a UI with Leptos — a framework commonly built with Trunk.
- Building Rust Wasm without wasm-pack — the steps Trunk runs internally.
- Hot reloading a Rust Wasm crate during development — the same loop for JavaScript-hosted crates.
- Compressing Wasm with Brotli for delivery — what the post-build hook prepares.
← Back to Rust to Wasm Compilation Guide