#spa #axum #web

axum-spa

Function library to support serving a single page application from the API server

1 unstable release

0.1.0 Jul 10, 2025

#1789 in HTTP server

Custom license

18KB
77 lines

Axum SPA

This crate provides utility functions for working with front-end single-page applications served alongside an API in Axum.

Use Case

This is designed to enable the development of single-page applications (SPA) in Axum. The target design is a single executable that can serve both the API responses and the front-end assets. The executable can either be made available "bare" on the network, or behind a reverse proxy (e.g. nginx) but all of the traffic related to a single application can be served from one binary.

Furthermore, when building a single-page application(SPA), it's not uncommon to want to be able to do live-reload of your front-end code while working on it, your HTML and CSS, and the API all at once. This crate provides functions to serve files just from the filesystem, with with live-reload enabled, so that you can develop fluidly across the stack.

The interfaces for all these functions are designed to be as similar as possible so that development and production builds can do "the right thing" with a few extra considerations as possible.

How To

Set up your main.rs like:

use axum_spa;
use clap::Parser;

#[derive(Parser)]
struct Config {
    // XXX this doesn't make sense in Prod...
    #[cfg(all(debug_assertions,not(feature = "debug_embed")))]
    #[arg(long, env = "FRONTEND_PATH", default_value = "./frontend")]
    frontend_path: String,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Router::new()
        .nest("/api",
            root_api_router(rate_key)
    let app = spa(app, &config)?;

    let listener = tokio::net::TcpListener::bind(config.local_addr.to_string()).await.expect("couldn't bind on local addr");
    axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?; Ok(())
}

#[cfg(not(all(debug_assertions,not(feature = "debug_embed"))))]
use include_dir::{include_dir,Dir};

#[cfg(not(all(debug_assertions,not(feature = "debug_embed"))))]
static ASSETS_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/frontend");

#[cfg(not(all(debug_assertions,not(feature = "debug_embed"))))]
fn spa(router: Router<AppState>, _config: &Config) -> Result<Router<AppState>, Box<dyn std::error::Error>> {
    spa::embedded(router, &ASSETS_DIR)
}

#[cfg(all(debug_assertions,not(feature = "debug_embed")))]
fn spa(router: Router<AppState>, config: &Config) -> Result<Router<AppState>, Box<dyn std::error::Error>> {
    spa::leaked_livereload(router, &config.frontend_path)
}

Run your development server like:

watchexec --no-vcs-ignore --on-busy-update restart --watch target/debug/ --filter myapp-backend target/debug/myapp-backend

Open your app in your browser and start development!

Changes to your frontend code will be reflected live. cargo builds will trigger a reload as well.

Once you're ready to deploy, cargo build --release will build your assets into your binary, ready to be shipped as a single unit.

Dependencies

~8–22MB
~248K SLoC