April 3, 2022

A Rust web server / frontend setup like it's 2022 (with axum and yew)

WebAssembly tooling for Rust has made big improvements and has matured tremendously over the previous two years. The build and packaging steps are now simpler thanks to tools like Trunk, as well as being able to write frontend code with various frameworks such as yew or dioxus. Also support for wasm is now supported in quite a few crates, basic ones such as chrono or higher level libraries such as plotters.

Additionally, the available options on the Rust server-side have increased. With projects like tower that provide reusable building blocks for clients / servers, web servers like axum came about that allow to quickly put together web apps without much boilerplate.

In this walkthrough I will describe my current default project setup for web projects that use Rust for frontend and backend. It is suitable for typical single-page web apps that use WASM/JS for rendering and routing. I have used it for dashboards, browser games and such but it should be suitable for any web app that wants to use a Rust server and frontend. I am choosing axum for the server part and yew for the frontend but it should work similarly with other choices. As for the features it will provide:

  • Running server / frontend in dev mode with automatic reload
  • Running server / client in production mode with pre-compiled sources
  • Serving additional files from a dist/ directory
  • Stub to allow adding more custom server logic

You can find the full code at https://github.com/rksm/axum-yew-setup. You can also use this code as a template to generate a new project using cargo-generate: cargo generate rksm/axum-yew-template.

Table of Contents

Changelog

  • 2023-03-13: No more axum-extra SPA router (thanks u/harunahi and u/LiftingBanana)
  • 2023-01-15: Don’t need cargo-edit anymore (thanks u/lucca_huguet)
  • 2023-01-09: Update for yew v0.20 (thanks to reddit user dvmitto for the nudge!)
  • 2022-09-13: Note about strip = "symbols" breaks wasm code
  • 2022-07-11: Trunk now seems to support workspace projects and won’t recompile from scratch when the server code changes. So this guide now uses a workspace setup. Also fix adding the dependencies, there was an issue with specifying the crate features.
  • 2022-04-19: Fixed Trunk command (trunk serve, not trunk dev) and slightly shorter invocation of cargo watch. Thanks @trysetnull!
  • 2022-04-14: Update requirements (Rust wasm target and trunk always-rebuild-from-scratch-issue)
  • 2022-04-08: Fix for the prod.sh script when using SpaRouter

Tools required

This walkthrough relies on the following Rust tooling:

  • The Rust wasm32 target. You can install it with rustup target add wasm32-unknown-unknown (The target specification “triple” is the architecture, vendor and OS type. “unknown” means don’t assume a runtime).
  • Trunk, a WASM web application bundler for Rust. Install with cargo install trunk.
  • cargo-watch for restarting commands on file change with cargo watch .... Install with cargo install cargo-watch.

Project structure

The directory and file structure is simple, we will setup a rust workspace with server and frontend subdirectories that host the server and frontend sub-projects.

flowchart TD; root([web-wasm-project]) server([server]); frontend([frontend]); root --- server; root --- cargo_root("Cargo.toml (workspace)") root --- frontend; subgraph a server --- cargo_1(Cargo.toml) server --- main_1(src/main.rs) end subgraph b frontend --- cargo_2(Cargo.toml) frontend --- trunk_2(Trunk.toml) frontend --- index_2(index.html) frontend --- main_2(src/main.rs) end

We can set this up like that:

mkdir web-wasm-project
cd web-wasm-project
cargo new --bin server --vcs none
cargo new --bin frontend --vcs none
echo -e '[workspace]\nmembers = ["server", "frontend"]' > Cargo.toml
echo -e "target/\ndist/" > .gitignore
git init

Server

First, we add the depencies for the server:

cd server
cargo add \
  axum \
  log \
  tower \
  tracing \
  tracing-subscriber \
  clap --features clap/derive \
  tokio --features tokio/full \
  tower-http --features tower-http/full

We can now add code for starting the axum server and serving a simple text output to get started. Edit server/src/main.rs to match:

use axum::{response::IntoResponse, routing::get, Router};
use clap::Parser;
use std::net::{IpAddr, Ipv6Addr, SocketAddr};
use std::str::FromStr;

// Setup the command line interface with clap.
#[derive(Parser, Debug)]
#[clap(name = "server", about = "A server for our wasm project!")]
struct Opt {
    /// set the listen addr
    #[clap(short = 'a', long = "addr", default_value = "::1")]
    addr: String,

    /// set the listen port
    #[clap(short = 'p', long = "port", default_value = "8080")]
    port: u16,
}

#[tokio::main]
async fn main() {
    let opt = Opt::parse();

    let app = Router::new().route("/", get(hello));

    let sock_addr = SocketAddr::from((
        IpAddr::from_str(opt.addr.as_str()).unwrap_or(IpAddr::V6(Ipv6Addr::LOCALHOST)),
        opt.port,
    ));

    println!("listening on http://{}", sock_addr);

    axum::Server::bind(&sock_addr)
        .serve(app.into_make_service())
        .await
        .expect("Unable to start server");
}

async fn hello() -> impl IntoResponse {
    "hello from server!"
}

The server can be started with cargo run --bin server from the project root directory. You should see the “hello from server!” response at [::1]:8080 and localhost:8080 (if you want to use the IPv4 address).

Adding logging to the server

For an HTTP server it is useful to log requests. Axum and tower already use tracing to provide async-aware structured logging. We can setup tracing-subscriber to log those traces to stdout and also extend the command-line interface to accept setting the log verbosity via cli arguments.

diff --git a/server/src/main.rs b/server/src/main.rs
index 0000000..0000000 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -2,11 +2,17 @@ use axum::{response::IntoResponse, routing::get, Router};
 use clap::Parser;
 use std::net::{IpAddr, Ipv6Addr, SocketAddr};
 use std::str::FromStr;
+use tower::ServiceBuilder;
+use tower_http::trace::TraceLayer;
 
 // Setup the command line interface with clap.
 #[derive(Parser, Debug)]
 #[clap(name = "server", about = "A server for our wasm project!")]
 struct Opt {
+    /// set the log level
+    #[clap(short = 'l', long = "log", default_value = "debug")]
+    log_level: String,
+
     /// set the listen addr
     #[clap(short = 'a', long = "addr", default_value = "::1")]
     addr: String,
@@ -20,14 +26,23 @@ struct Opt {
 async fn main() {
     let opt = Opt::parse();
 
-    let app = Router::new().route("/", get(hello));
+    // Setup logging & RUST_LOG from args
+    if std::env::var("RUST_LOG").is_err() {
+        std::env::set_var("RUST_LOG", format!("{},hyper=info,mio=info", opt.log_level))
+    }
+    // enable console logging
+    tracing_subscriber::fmt::init();
+
+    let app = Router::new()
+        .route("/", get(hello))
+        .layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()));
 
     let sock_addr = SocketAddr::from((
         IpAddr::from_str(opt.addr.as_str()).unwrap_or(IpAddr::V6(Ipv6Addr::LOCALHOST)),
         opt.port,
     ));
 
-    println!("listening on http://{}", sock_addr);
+    log::info!("listening on http://{}", sock_addr);
 
     axum::Server::bind(&sock_addr)
         .serve(app.into_make_service())

We will modify the server later to serve static content as well but for now let’s stop it (Ctrl-C) and first setup the frontend.

Frontend

We will use Trunk to bundle and serve (for development) the frontend code. You can install trunk with cargo install trunk. (As of this writing trunk version 0.16 is the most recent version and what was used for testing.)

Let’s first add all the dependencies we (eventually) need:

cd frontend;
cargo add \
  console_error_panic_hook \
  gloo-net \
  log \
  wasm-bindgen-futures \
  wasm-logger \
  yew --features yew/csr \
  yew-router

We are missing frontend/index.html which Trunk will use as the base html template. Create that file and edit it to match:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon"type="image/x-icon" href="data:image/x-icon;,">
    <title>Yew App</title>
  </head>
  <body>loading...</body>
</html>

Now edit frontend/src/main.rs for rendering a simple yew component:

use yew::prelude::*;
use yew_router::prelude::*;

#[derive(Clone, Routable, PartialEq)]
enum Route {
    #[at("/")]
    Home,
}

fn switch(routes: Route) -> Html {
    match routes {
        Route::Home => html! { <h1>{ "Hello Frontend" }</h1> },
    }
}

#[function_component(App)]
fn app() -> Html {
    html! {
        <BrowserRouter>
            <Switch<Route> render={switch} />
        </BrowserRouter>
    }
}
fn main() {
    wasm_logger::init(wasm_logger::Config::new(log::Level::Trace));
    console_error_panic_hook::set_once();
    yew::Renderer::<App>::new().render();
}

We are already using the yew router to make it easier to extend the app later. Running trunk serve (from the frontend directory) will now start the frontend in dev mode at localhost:8080. Making a change to the frontend code will automatically reload that page. Hot-reload & Rust — finally :)

Connecting frontend and server

Right now, the frontend and server only run individually but are not connected in a way that would allow them to run together in development or production mode. For production mode (serving pre-compiled frontend files without Trunk) to work properly we will need to serve those static files. To also allow a single-page app (SPA) app that uses different URL path in the frontend, we need to fallback to serve index.html for non-existing paths.

Let’s first modify the frontend to compile into a dist/ directory in the project root folder where files can be found by the server. Create a file frontend/Trunk.toml with the content:

[build]
target = "index.html"
dist = "../dist"

We can now modify the server to deliver the pre-compiled files. We make three changes to the server code:

  1. We will allow to configure a static_dir directory that defaults to the dist/ folder in the project root directory. This is where we have configured Trunk to output compiled files in Trunk.toml above.
  2. We will use tower_http::services::ServeDir to actually create a response for requests matching a file path. We will do that by adding a fallback handler to the server-side Router.
  3. We move our “hello” route to /api/hello, we will query that later from the frontend to showcase client-server interaction.
diff --git a/server/src/main.rs b/server/src/main.rs
index 0000000..0000000 100644
@@ -1,8 +1,11 @@
+use axum::body::{boxed, Body};
+use axum::http::{Response, StatusCode};
 use axum::{response::IntoResponse, routing::get, Router};
 use clap::Parser;
 use std::net::{IpAddr, Ipv6Addr, SocketAddr};
 use std::str::FromStr;
-use tower::ServiceBuilder;
+use tower::{ServiceBuilder, ServiceExt};
+use tower_http::services::ServeDir;
 use tower_http::trace::TraceLayer;
 
 // Setup the command line interface with clap.
@@ -20,6 +23,10 @@ struct Opt {
     /// set the listen port
     #[clap(short = 'p', long = "port", default_value = "8080")]
     port: u16,
+
+    /// set the directory where static files are to be found
+    #[clap(long = "static-dir", default_value = "./dist")]
+    static_dir: String,
 }
 
 #[tokio::main]
@@ -34,7 +41,16 @@ async fn main() {
     tracing_subscriber::fmt::init();
 
     let app = Router::new()
-        .route("/", get(hello))
+        .route("/api/hello", get(hello))
+        .fallback_service(get(|req| async move {
+            match ServeDir::new(opt.static_dir).oneshot(req).await {
+                Ok(res) => res.map(boxed),
+                Err(err) => Response::builder()
+                    .status(StatusCode::INTERNAL_SERVER_ERROR)
+                    .body(boxed(Body::from(format!("error: {err}"))))
+                    .expect("error response"),
+            }
+        }))
         .layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()));
 
     let sock_addr = SocketAddr::from((

If you now run trunk build in the frontend/ directory and cargo run --bin server in the root directory, the server should startup again, this time bringing up the yew app at localhost:8080 again, but served statically!

Custom server requests and frontend routing

Let’s query the /api/hello from the frontend.

First we will create a new yew component whose whole purpose it is to make a HTTP request to /api/hello and display it. Add it to frontend/main.rs:

#[function_component(HelloServer)]
fn hello_server() -> Html {
    let data = use_state(|| None);

    // Request `/api/hello` once
    {
        let data = data.clone();
        use_effect(move || {
            if data.is_none() {
                spawn_local(async move {
                    let resp = Request::get("/api/hello").send().await.unwrap();
                    let result = {
                        if !resp.ok() {
                            Err(format!(
                                "Error fetching data {} ({})",
                                resp.status(),
                                resp.status_text()
                            ))
                        } else {
                            resp.text().await.map_err(|err| err.to_string())
                        }
                    };
                    data.set(Some(result));
                });
            }

            || {}
        });
    }

    match data.as_ref() {
        None => {
            html! {
                <div>{"No server response"}</div>
            }
        }
        Some(Ok(data)) => {
            html! {
                <div>{"Got server response: "}{data}</div>
            }
        }
        Some(Err(err)) => {
            html! {
                <div>{"Error requesting data from server: "}{err}</div>
            }
        }
    }
}

In addition we will also setup an additional frontend route to render the new component at localhost:8080/hello-server:

diff --git a/frontend/src/main.rs b/frontend/src/main.rs
index 0000000..0000000 100644
--- a/frontend/src/main.rs
+++ b/frontend/src/main.rs
@@ -1,3 +1,5 @@
+use gloo_net::http::Request;
+use wasm_bindgen_futures::spawn_local;
 use yew::prelude::*;
 use yew_router::prelude::*;
 
@@ -5,11 +7,14 @@ use yew_router::prelude::*;
 enum Route {
     #[at("/")]
     Home,
+    #[at("/hello-server")]
+    HelloServer,
 }
 
 fn switch(routes: &Route) -> Html {
     match routes {
         Route::Home => html! { <h1>{ "Hello Frontend" }</h1> },
+        Route::HelloServer => html! { <HelloServer /> },
     }
 } 

Now let’s test it! Start the server on port 8081 and the frontend in dev mode with a proxy for api requests:

  • cargo run --bin server -- --port 8081
  • cd frontend; trunk serve --proxy-backend=http://[::1]:8081/api/

When you open your web browser at localhost:8080/hello-server you should now see:

But what happens when we serve the app statically?

Oh no.

Making the file server support a SPA app

The reason for that is of course that the server tries to find the path /hello-server in the file system due to the fallback handler as no other server-side route matches it. The client-side routes require index.html to be loaded so that the JavaScript / WASM code can handle the request as we expect in a single-page application. In order to make it work we will need to have our fallback handler not give a 404 response when it cannot find a resource but return the index file:

diff --git a/server/src/main.rs b/server/src/main.rs
index 0000000..0000000 100644
@@ -3,7 +3,9 @@ use axum::http::{Response, StatusCode};
 use axum::{response::IntoResponse, routing::get, Router};
 use clap::Parser;
 use std::net::{IpAddr, Ipv6Addr, SocketAddr};
+use std::path::PathBuf;
 use std::str::FromStr;
+use tokio::fs;
 use tower::{ServiceBuilder, ServiceExt};
 use tower_http::services::ServeDir;
 use tower_http::trace::TraceLayer;
@@ -43,8 +45,28 @@ async fn main() {
     let app = Router::new()
         .route("/api/hello", get(hello))
         .fallback(get(|req| async move {
-            match ServeDir::new(opt.static_dir).oneshot(req).await {
-                Ok(res) => res.map(boxed),
+            match ServeDir::new(&opt.static_dir).oneshot(req).await {
+                Ok(res) => {
+                    let status = res.status();
+                    match status {
+                        StatusCode::NOT_FOUND => {
+                            let index_path = PathBuf::from(&opt.static_dir).join("index.html");
+                            let index_content = match fs::read_to_string(index_path).await {
+                                Err(_) => {
+                                    return Response::builder()
+                                        .status(StatusCode::NOT_FOUND)
+                                        .body(boxed(Body::from("index file not found")))
+                                        .unwrap()
+                                }
+                                Ok(index_content) => index_content,
+                            };
+    
+                            Response::builder()
+                                .status(StatusCode::OK)
+                                .body(boxed(Body::from(index_content)))
+                                .unwrap()
+                        }
+                        _ => res.map(boxed),
+                    }
+                }
                 Err(err) => Response::builder()
                     .status(StatusCode::INTERNAL_SERVER_ERROR)
                     .body(boxed(Body::from(format!("error: {err}"))))

Conveniently running it

For running the dev or static version I prefer putting the necessary cargo commands into scripts. dev.sh runs both Trunk and the server at the same time. The server is started using cargo-watch, a useful utility to detect file changes. In combination with trunk serve this will auto-reload both client and server!

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

(trap 'kill 0' SIGINT; \
 bash -c 'cd frontend; trunk serve --proxy-backend=http://[::1]:8081/api/' & \
 bash -c 'cargo watch -- cargo run --bin server -- --port 8081')

prod.sh will build the frontend and run the server:

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

pushd frontend
trunk build
popd

cargo run --bin server --release -- --port 8080 --static-dir ./dist

And this is it! A complete starter pack for a full Rust web app!

A note about a bug stripping symbols in wasm code

Please note that there is an open rust compiler bug when enabling stripping symbols that breaks wasm code.

As reported by @malaire, a setting like profile.release.strip = true will result in the example above not loading. For the time being, only strip = "debuginfo" is working.

A note on Trunk watch and serve

Should you observe that trunk serve is rebuilding your frontend project from scratch each time you save a file, this can be one of two things:

There is an open issue with the file watcher that won’t debounce file change events. Just recently (2022-04-13) a fix was merged that is currently only on Trunk master. Use cargo install trunk --git https://github.com/thedodd/trunk for installing the most recent version. (Update on 2023-01-09: This seems now to be in the Trunk release version as well.)

On Linux even this did not fix the behavior for me. I needed to specify I separate cargo target dir to untangle rust-analyzer from trunks build directory: Using bash -c 'cd frontend; CARGO_TARGET_DIR=target-trunk trunk serve' in dev.sh fixed that.

What to deploy

If you want to deploy just the server and dist repo to some machine, you will only need to build the frontend and the server binary (cd server; cargo build --release), take server/target/release/server and dist/ and put it into the same directory. If you then start the server with --static-dir ./dist it will run as well.

© Robert Krahn 2009-2023