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-unknown target.
  • [ ] Trunk 0.21+ (cargo install trunk --locked, or a release binary via cargo 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.

How Trunk turns index.html into a deployable site Trunk reads index.html, finds data-trunk links for the Rust crate and assets, builds the crate with cargo and wasm-bindgen, processes CSS and copies static files, then writes hashed files and a rewritten index.html into dist. index.html data-trunk links cargo + wasm-bindgen crate → .wasm + glue assets pipeline css, scss, images, copy-dir hash + rewrite content-hashed names dist/ static files to deploy

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.

What lands in dist and how to cache it The dist directory produced by trunk build: a small index.html that should be revalidated, content-hashed JavaScript glue, Wasm module and CSS that can be cached immutably, and copied static assets whose caching depends on whether they are versioned. index.html rewritten references; serve with Cache-Control: no-cache app-<hash>_bg.wasm the module; immutable, one year app-<hash>.js wasm-bindgen glue and loader; immutable main-<hash>.css processed stylesheet; immutable assets/ copied as-is; cache by your own versioning

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 a main function; add src/main.rs, or point data-bin at the right one.
  • The page is blank and the console shows a panic. main panicked before rendering anything, often on an unwrap() of a missing DOM element. console_error_panic_hook turns that into a readable message; install it first thing in main.
  • 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-opt applies only to --release builds, 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-bindgen on 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.

Module size by Trunk build configuration The same Rust front end built three ways with Trunk. The release profile with wasm-opt -Oz is a small fraction of the dev build. uncompressed .wasm size (KB) trunk serve (dev profile) 2,140 KB --release, no wasm-opt 158 KB --release, wasm-opt -Oz 96 KB

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.

← Back to Rust to Wasm Compilation Guide