Web Server with Axum

Axum is the HTTP framework for this stack. Built on Tower and Hyper, it provides type-safe request handling through extractors and uses the same Tower middleware that the rest of the Rust async ecosystem uses.

This section covers routing, handlers, extractors, shared state, middleware, static assets, and graceful shutdown. A complete runnable server is assembled at the end.

A minimal server

Add Axum and Tokio to your Cargo.toml:

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }

A server that responds to GET /:

use axum::{Router, routing::get};

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(|| async { "hello" }));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    axum::serve(listener, app).await.unwrap();
}

axum::serve binds the router to a TCP listener. There is no separate Server type.

Handlers

A handler is an async function that receives zero or more extractors and returns something that implements IntoResponse:

use axum::response::Html;

async fn index() -> Html<&'static str> {
    Html("<h1>Home</h1>")
}

Axum provides IntoResponse implementations for common types: String, &str, StatusCode, Html<T>, Json<T>, and tuples that combine a status code with a body.

use axum::{http::StatusCode, response::IntoResponse};

async fn not_found() -> impl IntoResponse {
    (StatusCode::NOT_FOUND, Html("<h1>404</h1>"))
}

In a hypermedia-driven application, most handlers return Html. The JSON response types exist but are rarely the primary format.

Debugging handler signatures

Enable the macros feature and annotate handlers with #[debug_handler] during development. It produces clearer compiler errors when an extractor or return type is wrong:

axum = { version = "0.8", features = ["macros"] }
use axum::debug_handler;

#[debug_handler]
async fn index() -> Html<&'static str> {
    Html("<h1>Home</h1>")
}

Remove #[debug_handler] before release. It adds overhead that is only useful during compilation.

Extractors

Extractors pull data out of the incoming request. Axum calls FromRequestParts (for headers, path parameters, query strings) or FromRequest (for the body) on each handler argument. A body-consuming extractor must be the last argument.

Common extractors:

ExtractorSourceExample
Path<T>URL path parametersPath(id): Path<u64>
Query<T>Query stringQuery(params): Query<SearchParams>
Form<T>URL-encoded bodyForm(data): Form<LoginForm>
State<T>Shared application stateState(state): State<AppState>
HeaderMapRequest headersheaders: HeaderMap
use axum::extract::{Path, Query, State};
use axum::response::Html;
use serde::Deserialize;

#[derive(Deserialize)]
struct SearchParams {
    q: Option<String>,
    page: Option<u32>,
}

async fn search(
    State(state): State<AppState>,
    Query(params): Query<SearchParams>,
) -> Html<String> {
    // use state.db and params.q to query and render results
    Html(format!("<p>Searching for {:?}</p>", params.q))
}

Path parameters use curly-brace syntax in route definitions. This changed in Axum 0.8; the older colon syntax (:id) no longer works:

// /{id} not /:id
app.route("/users/{id}", get(show_user));

async fn show_user(Path(id): Path<u64>) -> impl IntoResponse {
    Html(format!("<h1>User {id}</h1>"))
}

Application state

Shared state is how handlers access the database pool, configuration, and other application-wide resources. Define a struct, derive Clone, and pass it to the router with with_state:

use sqlx::PgPool;

#[derive(Clone)]
struct AppState {
    db: PgPool,
    config: AppConfig,
}

#[derive(Clone)]
struct AppConfig {
    app_name: String,
    base_url: String,
}

Wire the state into the router:

let state = AppState {
    db: PgPool::connect(&database_url).await.unwrap(),
    config: AppConfig {
        app_name: "My App".into(),
        base_url: "http://localhost:3000".into(),
    },
};

let app = Router::new()
    .route("/", get(index))
    .with_state(state);

Handlers extract it with State<AppState>:

async fn index(State(state): State<AppState>) -> Html<String> {
    Html(format!("<h1>{}</h1>", state.config.app_name))
}

Router<S> means the router is missing state of type S. Calling .with_state(state) produces Router<()>, meaning all state has been provided. Only Router<()> can be passed to axum::serve.

PgPool is internally reference-counted, so cloning AppState is cheap. For fields that need interior mutability (counters, caches), wrap them in Arc<RwLock<T>>.

Route organisation with nest

Router::nest mounts a sub-router under a path prefix. Use this to organise routes by feature or domain area:

fn user_routes() -> Router<AppState> {
    Router::new()
        .route("/", get(list_users).post(create_user))
        .route("/{id}", get(show_user))
        .route("/{id}/edit", get(edit_user_form).post(update_user))
}

fn admin_routes() -> Router<AppState> {
    Router::new()
        .route("/", get(admin_dashboard))
        .route("/users", get(admin_users))
}

let app = Router::new()
    .route("/", get(index))
    .nest("/users", user_routes())
    .nest("/admin", admin_routes())
    .with_state(state);

Requests to /users/42 reach show_user with the path /42. The prefix is stripped before the nested router sees the request. If a handler needs the full original URI, extract OriginalUri from axum::extract.

In a workspace with multiple crates, define route functions in each crate and assemble them in the binary crate:

// in src/main.rs
use users::user_routes;
use admin::admin_routes;

let app = Router::new()
    .nest("/users", user_routes())
    .nest("/admin", admin_routes())
    .with_state(state);

All nested routers must share the same state type. If a sub-router has its own state, call .with_state() on it before nesting:

let inner = Router::new()
    .route("/bar", get(inner_handler))
    .with_state(InnerState {});  // becomes Router<()>

let app = Router::new()
    .nest("/foo", inner)  // Router<()> nests into any parent
    .with_state(OuterState {});

Middleware

Axum uses Tower layers for middleware. The tower-http crate provides HTTP-specific layers that cover most common needs.

[dependencies]
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "compression-gzip"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

Request tracing

TraceLayer logs every request and response, integrating with the tracing crate:

use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;

tracing_subscriber::fmt()
    .with_env_filter(EnvFilter::from_default_env())
    .init();

let app = Router::new()
    .route("/", get(index))
    .layer(TraceLayer::new_for_http());

Control log levels with the RUST_LOG environment variable: RUST_LOG=info for production, RUST_LOG=tower_http=trace during development.

Response compression

CompressionLayer compresses response bodies. Enable additional algorithms by adding features like compression-br or compression-zstd:

use tower_http::compression::CompressionLayer;

let app = Router::new()
    .route("/", get(index))
    .layer(CompressionLayer::new())
    .layer(TraceLayer::new_for_http());

Combining layers

Apply multiple layers with ServiceBuilder. Layers are listed top-to-bottom, and the first layer listed is the outermost (runs first on the request, last on the response):

use tower::ServiceBuilder;

let app = Router::new()
    .route("/", get(index))
    .layer(
        ServiceBuilder::new()
            .layer(TraceLayer::new_for_http())
            .layer(CompressionLayer::new())
    )
    .with_state(state);

Here, tracing wraps compression: requests are logged before responses are compressed.

Sessions and CSRF

Session management (tower-sessions) and CSRF protection follow the same .layer() pattern. They are covered in the Authentication section.

Custom middleware

For application-specific middleware, use axum::middleware::from_fn. Write a plain async function that receives the request and a Next handle:

use axum::{
    middleware::{self, Next},
    extract::Request,
    response::Response,
    http::StatusCode,
};

async fn require_auth(
    State(state): State<AppState>,
    request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    // check auth, return Err(StatusCode::UNAUTHORIZED) if invalid
    Ok(next.run(request).await)
}

let app = Router::new()
    .route("/dashboard", get(dashboard))
    .route_layer(middleware::from_fn_with_state(
        state.clone(),
        require_auth,
    ))
    .with_state(state);

.route_layer() applies middleware only to matched routes. Unmatched requests fall through to the fallback without hitting this middleware. .layer() applies to all requests, including fallbacks.

Serving static assets

An HDA application typically serves a small set of CSS and JavaScript files. The rust-embed crate embeds an entire directory into the binary at compile time, producing a single self-contained executable.

[dependencies]
rust-embed = "8"
mime_guess = "2"

Define an embedded asset struct pointing at your assets directory:

use rust_embed::RustEmbed;

#[derive(RustEmbed)]
#[folder = "assets/"]
struct Assets;

Write a handler that serves embedded files:

use axum::{
    extract::Path,
    http::{header, StatusCode},
    response::IntoResponse,
};

async fn static_handler(Path(path): Path<String>) -> impl IntoResponse {
    match Assets::get(&path) {
        Some(file) => {
            let mime = mime_guess::from_path(&path).first_or_octet_stream();
            (
                [(header::CONTENT_TYPE, mime.as_ref())],
                file.data.to_vec(),
            )
                .into_response()
        }
        None => StatusCode::NOT_FOUND.into_response(),
    }
}

Mount it on the router:

let app = Router::new()
    .route("/", get(index))
    .route("/assets/{*path}", get(static_handler));

In debug builds, rust-embed reads files from disk, so changes to CSS and JavaScript appear without recompilation. In release builds, everything is baked into the binary.

If your project grows to include many large assets (images, fonts), consider tower-http’s ServeDir to serve from the filesystem instead, or move large files to object storage.

Graceful shutdown

axum::serve accepts a shutdown signal via .with_graceful_shutdown(). When the signal fires, the server stops accepting new connections and waits for in-flight requests to complete.

use tokio::signal;

async fn shutdown_signal() {
    let ctrl_c = async {
        signal::ctrl_c()
            .await
            .expect("failed to install Ctrl+C handler");
    };

    #[cfg(unix)]
    let terminate = async {
        signal::unix::signal(signal::unix::SignalKind::terminate())
            .expect("failed to install SIGTERM handler")
            .recv()
            .await;
    };

    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }
}

Pass the signal to the server:

axum::serve(listener, app)
    .with_graceful_shutdown(shutdown_signal())
    .await
    .unwrap();

This handles both Ctrl+C (SIGINT) and SIGTERM, which is what Docker and most process managers send when stopping a container. In production, consider adding a TimeoutLayer from tower-http so that slow in-flight requests cannot block shutdown indefinitely.

Putting it together

A complete main.rs combining routing, state, middleware, static assets, and graceful shutdown:

use axum::{
    extract::{Path, State},
    http::{header, StatusCode},
    response::{Html, IntoResponse},
    routing::get,
    Router,
};
use rust_embed::RustEmbed;
use sqlx::PgPool;
use tokio::signal;
use tower::ServiceBuilder;
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
use tracing_subscriber::EnvFilter;

// -- State --

#[derive(Clone)]
struct AppState {
    db: PgPool,
    config: AppConfig,
}

#[derive(Clone)]
struct AppConfig {
    app_name: String,
}

// -- Static assets --

#[derive(RustEmbed)]
#[folder = "assets/"]
struct Assets;

async fn static_handler(Path(path): Path<String>) -> impl IntoResponse {
    match Assets::get(&path) {
        Some(file) => {
            let mime = mime_guess::from_path(&path).first_or_octet_stream();
            ([(header::CONTENT_TYPE, mime.as_ref())], file.data.to_vec())
                .into_response()
        }
        None => StatusCode::NOT_FOUND.into_response(),
    }
}

// -- Handlers --

async fn index(State(state): State<AppState>) -> Html<String> {
    Html(format!(
        r#"<html>
  <head><link rel="stylesheet" href="/assets/style.css"></head>
  <body><h1>{}</h1></body>
</html>"#,
        state.config.app_name
    ))
}

// -- User routes --

fn user_routes() -> Router<AppState> {
    Router::new()
        .route("/", get(list_users))
        .route("/{id}", get(show_user))
}

async fn list_users() -> Html<&'static str> {
    Html("<h1>Users</h1>")
}

async fn show_user(Path(id): Path<u64>) -> Html<String> {
    Html(format!("<h1>User {id}</h1>"))
}

// -- Shutdown --

async fn shutdown_signal() {
    let ctrl_c = async {
        signal::ctrl_c()
            .await
            .expect("failed to install Ctrl+C handler");
    };

    #[cfg(unix)]
    let terminate = async {
        signal::unix::signal(signal::unix::SignalKind::terminate())
            .expect("failed to install SIGTERM handler")
            .recv()
            .await;
    };

    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }
}

// -- Main --

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::from_default_env())
        .init();

    let database_url =
        std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");

    let state = AppState {
        db: PgPool::connect(&database_url).await.unwrap(),
        config: AppConfig {
            app_name: "My App".into(),
        },
    };

    let app = Router::new()
        .route("/", get(index))
        .nest("/users", user_routes())
        .route("/assets/{*path}", get(static_handler))
        .layer(
            ServiceBuilder::new()
                .layer(TraceLayer::new_for_http())
                .layer(CompressionLayer::new()),
        )
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    tracing::info!("listening on {}", listener.local_addr().unwrap());

    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await
        .unwrap();
}

The corresponding dependencies:

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "compression-gzip"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] }
rust-embed = "8"
mime_guess = "2"
serde = { version = "1", features = ["derive"] }