rustaxumapibackendtutorial

Getting Started with Axum - Rust's Fastest-Growing Web Framework

ยท24 min read

Getting Started with Axum - Rust's Fastest-Growing Web Framework

Axum is the most downloaded Rust web framework on crates.io. It lives in the Tokio project and combines ergonomic API design with the full power of the Tower middleware ecosystem. If you're building a backend in Rust in 2026, Axum is a great place to start.

This guide walks you through everything you need to know - from your first route to a complete CRUD API. Every code example in this article compiles against Axum 0.8.9 (the latest release as of September 2026), together with tower-http 0.7, SQLx 0.9 and jsonwebtoken 11.

Table of Contents


Why Axum?

Before we write any code, here's why Axum has become the default choice for so many Rust backends:

  1. No routing macros required. Routes are plain Rust functions. No #[get("/")] decorators, no magic - just types and functions.
  2. Tower compatibility. Axum is built on tower::Service, so Tower and tower-http middleware plug straight in: tracing, compression, CORS, timeouts, body limits and more.
  3. Part of the Tokio project. Axum is developed under the tokio-rs GitHub organization, alongside the async runtime that powers most of the ecosystem.
  4. Type-safe extractors. Handler arguments declare what they need from the request. The compiler checks that every argument is a valid extractor, and Axum parses the request into those types for you (returning a 4xx response when the input doesn't match).
  5. Readable handler errors. When a handler doesn't satisfy Axum's trait bounds, the #[debug_handler] macro (behind the macros feature) turns a wall of trait errors into a message that points at the offending argument or return type.

Axum at a Glance

FeatureAxum 0.8
Async RuntimeTokio
Middleware SystemTower / tower-http
Macro-free routingYes
Path parameter syntax/{id} (new in 0.8)
Native async traitsYes (no #[async_trait] for extractors)
WebSocket supportBuilt-in, behind the ws feature
HTTP/2 supportBehind the http2 feature
OpenAPI generationVia the utoipa and utoipa-axum crates

Quick Start: Hello World in 60 Seconds

Create a new project and add dependencies:

cargo new axum-api
cd axum-api
cargo add axum@0.8 tokio -F tokio/full

Replace src/main.rs:

use axum::{Router, routing::get};
 
#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(|| async { "Hello, World!" }));
 
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Listening on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}
cargo run
# In another terminal:
curl http://localhost:3000
# => Hello, World!

That's a working HTTP server in 10 lines of Rust. No macros beyond #[tokio::main], no boilerplate, no framework magic. Let's build on it.


Routing Fundamentals

Axum's Router maps HTTP methods and paths to handler functions:

use axum::{Router, routing::get};
 
async fn index() -> &'static str { "Home" }
async fn about() -> &'static str { "About" }
 
let app = Router::new()
    .route("/", get(index))
    .route("/about", get(about));

Multiple Methods on One Route

Chain method handlers on a single path:

use axum::routing::get;
 
async fn list_users() -> &'static str { "List users" }
async fn create_user() -> &'static str { "Create user" }
 
let app = Router::new()
    .route("/users", get(list_users).post(create_user));

Path Parameters (New {param} Syntax in 0.8)

Axum 0.8 changed path parameter syntax from :param to {param}, which matches the syntax used by format!() and OpenAPI:

// Axum 0.8+ syntax
.route("/users/{id}", get(get_user))
.route("/files/{*path}", get(serve_file))  // wildcard
 
// Old syntax (pre-0.8) - panics when the route is added:
// .route("/users/:id", get(get_user))

Migration note: If you're upgrading from Axum 0.7, replace :param with {param} and *param with {*param} across all your route definitions. Axum 0.8 panics on segments that start with : or * so a missed route fails loudly at startup instead of silently never matching. If you genuinely need a literal : at the start of a segment, call Router::without_v07_checks().

Nested Routers

Split routes by feature using nest:

let user_routes = Router::new()
    .route("/", get(list_users).post(create_user))
    .route("/{id}", get(get_user).put(update_user).delete(delete_user));
 
let app = Router::new()
    .nest("/api/users", user_routes);
 
// Produces: GET /api/users, POST /api/users, GET /api/users/{id}, etc.

Note: Since 0.8, nesting at the root (.nest("/", ...)) panics. Use merge for routers and fallback_service for services instead.

Merging Routers

When you want to combine routers that each define their own full paths:

let app = Router::new()
    .merge(user_routes())
    .merge(health_routes());

Handler Functions and Responses

A handler is any async function that:

  1. Takes zero or more extractors as arguments
  2. Returns something that implements IntoResponse

Returning Different Response Types

Axum implements IntoResponse for many types out of the box:

use axum::Json;
use axum::http::StatusCode;
use axum::response::Html;
 
// Plain text
async fn plain() -> &'static str {
    "Hello"
}
 
// Status code only
async fn no_content() -> StatusCode {
    StatusCode::NO_CONTENT
}
 
// JSON
async fn json() -> Json<serde_json::Value> {
    Json(serde_json::json!({ "message": "Hello" }))
}
 
// HTML
async fn page() -> Html<&'static str> {
    Html("<h1>Hello</h1>")
}
 
// Tuple: (StatusCode, Body)
async fn created() -> (StatusCode, Json<serde_json::Value>) {
    (StatusCode::CREATED, Json(serde_json::json!({ "id": 1 })))
}

The JSON examples need serde and serde_json:

cargo add serde -F derive
cargo add serde_json

Custom Response Types with IntoResponse

For larger APIs, define an enum for your responses:

use axum::{
    Json,
    http::StatusCode,
    response::{IntoResponse, Response},
};
use serde::Serialize;
 
#[derive(Serialize)]
struct User {
    id: u64,
    name: String,
}
 
enum ApiResponse {
    Ok,
    Created,
    JsonData(Vec<User>),
}
 
impl IntoResponse for ApiResponse {
    fn into_response(self) -> Response {
        match self {
            Self::Ok => StatusCode::OK.into_response(),
            Self::Created => StatusCode::CREATED.into_response(),
            Self::JsonData(data) => (StatusCode::OK, Json(data)).into_response(),
        }
    }
}
 
async fn list_users() -> ApiResponse {
    ApiResponse::JsonData(vec![User {
        id: 1,
        name: "Alice".into(),
    }])
}

Extractors: Path, Query, JSON, Form

Extractors pull data out of incoming HTTP requests. They're passed as function parameters and Axum resolves them automatically. If extraction fails (a non-numeric id, malformed JSON, a missing field), Axum rejects the request with a 4xx response before your handler runs.

Path Parameters

use axum::extract::Path;
 
async fn get_user(Path(id): Path<u64>) -> String {
    format!("User #{id}")
}
 
// Multiple path params
async fn get_comment(
    Path((post_id, comment_id)): Path<(u64, u64)>,
) -> String {
    format!("Post {post_id}, Comment {comment_id}")
}
 
// Route: .route("/posts/{post_id}/comments/{comment_id}", get(get_comment))

Query Parameters

use axum::extract::Query;
use serde::Deserialize;
 
#[derive(Deserialize)]
struct Pagination {
    page: Option<u32>,
    per_page: Option<u32>,
}
 
async fn list_items(Query(pagination): Query<Pagination>) -> String {
    let page = pagination.page.unwrap_or(1);
    let per_page = pagination.per_page.unwrap_or(20);
    format!("Page {page}, {per_page} items")
}
 
// GET /items?page=2&per_page=50

JSON Body

use axum::Json;
use serde::Deserialize;
 
#[derive(Deserialize)]
struct CreateUser {
    name: String,
    email: String,
}
 
async fn create_user(Json(input): Json<CreateUser>) -> String {
    format!("Created user: {} ({})", input.name, input.email)
}

The Json extractor requires a Content-Type: application/json header and responds with 415 Unsupported Media Type without it.

Form Data

use axum::Form;
use serde::Deserialize;
 
#[derive(Deserialize)]
struct LoginForm {
    username: String,
    password: String,
}
 
async fn login(Form(input): Form<LoginForm>) -> String {
    format!("Login attempt: {}", input.username)
}

Multiple Extractors in One Handler

You can combine extractors freely. The only rule: at most one extractor can consume the request body (Json, Form, Bytes, etc.), and it must be the last parameter.

use axum::{
    Json,
    extract::{Path, Query, State},
    http::StatusCode,
};
use std::sync::Arc;
 
async fn update_item(
    State(state): State<Arc<AppState>>, // from app state
    Path(id): Path<u64>,                // from URL
    Query(params): Query<Pagination>,   // from query string
    Json(body): Json<UpdateItem>,       // from body (must be last)
) -> StatusCode {
    // ...
    StatusCode::OK
}

Custom Extractors (No #[async_trait] in 0.8)

Axum 0.8 removed the need for #[async_trait] when implementing extractors. FromRequest and FromRequestParts now use Rust's native async fn in traits:

use axum::{
    extract::FromRequestParts,
    http::{StatusCode, request::Parts},
};
 
struct CurrentUser {
    user_id: u64,
}
 
impl<S> FromRequestParts<S> for CurrentUser
where
    S: Send + Sync,
{
    type Rejection = StatusCode;
 
    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        // Extract user from headers, cookies, JWT, etc.
        let user_id = parts
            .headers
            .get("x-user-id")
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.parse().ok())
            .ok_or(StatusCode::UNAUTHORIZED)?;
 
        Ok(CurrentUser { user_id })
    }
}
 
// Use it like any other extractor
async fn profile(user: CurrentUser) -> String {
    format!("User #{}", user.user_id)
}

Application State with State

Real applications need shared state: database pools, configuration, caches. Axum's State extractor provides type-safe access to shared data. The state type must implement Clone, which is why it is usually wrapped in an Arc.

Basic State with Arc

use axum::{Json, Router, extract::State, routing::get};
use sqlx::PgPool;
use std::sync::Arc;
 
struct AppState {
    db_pool: PgPool,
    config: AppConfig,
}
 
#[tokio::main]
async fn main() {
    let state = Arc::new(AppState {
        db_pool: create_pool().await,
        config: load_config(),
    });
 
    let app = Router::new()
        .route("/users", get(list_users))
        .with_state(state);
 
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
 
async fn list_users(State(state): State<Arc<AppState>>) -> Json<Vec<User>> {
    let users = sqlx::query_as::<_, User>("SELECT id, name FROM users")
        .fetch_all(&state.db_pool)
        .await
        .unwrap();
    Json(users)
}

Substates for Access Control

Limit what specific routes can access. FromRef lets a handler extract just one field of the app state:

use axum::extract::{FromRef, State};
 
#[derive(Clone, FromRef)]
struct AppState {
    api_state: ApiState,
    admin_state: AdminState,
}
 
#[derive(Clone)]
struct ApiState {
    db_pool: PgPool,
}
 
#[derive(Clone)]
struct AdminState {
    admin_key: String,
}
 
// This handler can only access ApiState, not AdminState
async fn public_endpoint(State(api): State<ApiState>) -> &'static str {
    "Public"
}

#[derive(FromRef)] needs the macros feature (cargo add axum -F macros). Without it, write one impl FromRef<AppState> for ApiState per field that returns app.api_state.clone().


Error Handling That Scales

Axum's error handling model is straightforward: handlers return Result<T, E> where both T and E implement IntoResponse.

Define a Central Error Type

use axum::{
    Json,
    http::StatusCode,
    response::{IntoResponse, Response},
};
 
enum AppError {
    NotFound(String),
    BadRequest(String),
    Unauthorized,
    Internal(String),
}
 
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
            AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
            AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".into()),
            AppError::Internal(msg) => {
                tracing::error!("Internal error: {msg}");
                (StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong".into())
            }
        };
 
        (status, Json(serde_json::json!({ "error": message }))).into_response()
    }
}

Convert From Library Errors

Implement From to use the ? operator seamlessly:

impl From<sqlx::Error> for AppError {
    fn from(err: sqlx::Error) -> Self {
        match err {
            sqlx::Error::RowNotFound => AppError::NotFound("Resource not found".into()),
            _ => AppError::Internal(err.to_string()),
        }
    }
}
 
// Now handlers are clean:
async fn get_user(
    State(state): State<Arc<AppState>>,
    Path(id): Path<i64>,
) -> Result<Json<User>, AppError> {
    let user = sqlx::query_as::<_, User>("SELECT id, name FROM users WHERE id = $1")
        .bind(id)
        .fetch_one(&state.db_pool)
        .await?; // Automatically converts sqlx::Error -> AppError
    Ok(Json(user))
}

Middleware with Tower

Axum's superpower is its deep integration with Tower. Any Tower middleware whose error type is Infallible can be added with .layer(), and that covers almost everything in tower-http. Middleware that can fail (such as tower::timeout) must be wrapped in HandleErrorLayer so the error becomes a response.

Built-in Tower-HTTP Layers

cargo add tower-http -F trace,compression-full,cors,timeout,limit
use axum::http::StatusCode;
use std::time::Duration;
use tower_http::{
    compression::CompressionLayer, cors::CorsLayer, limit::RequestBodyLimitLayer,
    timeout::TimeoutLayer, trace::TraceLayer,
};
 
let app = Router::new()
    .route("/", get(index))
    .layer(TraceLayer::new_for_http())
    .layer(CompressionLayer::new())
    .layer(CorsLayer::permissive())
    .layer(TimeoutLayer::with_status_code(
        StatusCode::REQUEST_TIMEOUT,
        Duration::from_secs(30),
    ))
    .layer(RequestBodyLimitLayer::new(1024 * 1024)); // 1MB limit

Note: TimeoutLayer::new is deprecated since tower-http 0.6.7. Use TimeoutLayer::with_status_code so the status returned on timeout is explicit.

Layer order matters. Each .layer() call wraps everything added before it. The last .layer() call is the outermost layer (runs first on request, last on response). If you want top-to-bottom ordering, group layers with tower::ServiceBuilder.

Writing Custom Middleware

axum::middleware::from_fn turns an async function into middleware. The request type is axum::extract::Request (an alias for http::Request<axum::body::Body>):

use axum::{extract::Request, middleware::Next, response::Response};
 
async fn timing_middleware(req: Request, next: Next) -> Response {
    let start = std::time::Instant::now();
    let method = req.method().clone();
    let uri = req.uri().clone();
 
    let response = next.run(req).await;
 
    let duration = start.elapsed();
    tracing::info!("{method} {uri} -> {} in {duration:?}", response.status());
 
    response
}
 
// Apply it:
let app = Router::new()
    .route("/", get(index))
    .layer(axum::middleware::from_fn(timing_middleware));

Middleware with State

use axum::{
    extract::{Request, State},
    http::StatusCode,
    middleware::Next,
    response::Response,
};
 
async fn auth_middleware(
    State(state): State<Arc<AppState>>,
    req: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let token = req
        .headers()
        .get("authorization")
        .and_then(|v| v.to_str().ok())
        .ok_or(StatusCode::UNAUTHORIZED)?;
 
    // Validate token against your state
    if !validate_token(&state, token).await {
        return Err(StatusCode::UNAUTHORIZED);
    }
 
    Ok(next.run(req).await)
}
 
let app = Router::new()
    .route("/protected", get(protected_handler))
    .layer(axum::middleware::from_fn_with_state(state.clone(), auth_middleware))
    .with_state(state);

Applying Middleware to Specific Routes

Use route_layer to apply middleware only to routes defined above it:

let app = Router::new()
    .route("/admin", get(admin_panel))
    .route_layer(axum::middleware::from_fn(require_admin)) // only /admin
    .route("/public", get(public_page)); // no middleware

Unlike layer, route_layer only runs when a route matches, so requests to unknown paths still get a plain 404 instead of passing through your middleware.


Database Integration with SQLx

Here's how to wire up PostgreSQL with SQLx and Axum for a complete CRUD API.

Dependencies

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.9", features = ["runtime-tokio", "postgres", "uuid", "macros", "migrate"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
uuid = { version = "1", features = ["v4", "serde"] }

SQLx 0.9 requires Rust 1.94 or newer. The uuid feature lets SQLx encode and decode Postgres UUID columns as uuid::Uuid.

Setting Up the Connection Pool

use sqlx::{PgPool, postgres::PgPoolOptions};
 
struct AppState {
    db: PgPool,
}
 
#[tokio::main]
async fn main() {
    let database_url = std::env::var("DATABASE_URL")
        .unwrap_or_else(|_| "postgres://user:pass@localhost/mydb".into());
 
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(&database_url)
        .await
        .expect("Failed to connect to database");
 
    // Run migrations from ./migrations
    sqlx::migrate!("./migrations")
        .run(&pool)
        .await
        .expect("Failed to run migrations");
 
    let state = Arc::new(AppState { db: pool });
 
    let app = Router::new()
        .route("/todos", get(list_todos).post(create_todo))
        .route(
            "/todos/{id}",
            get(get_todo).put(update_todo).delete(delete_todo),
        )
        .with_state(state);
 
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

CRUD Handlers

These handlers use SQLx's runtime query API with #[derive(FromRow)], so they compile without a database. They reuse the AppError type from the error handling section.

use axum::{
    Json,
    extract::{Path, State},
    http::StatusCode,
};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
 
#[derive(Debug, Serialize, FromRow)]
struct Todo {
    id: Uuid,
    title: String,
    completed: bool,
}
 
#[derive(Debug, Deserialize)]
struct CreateTodo {
    title: String,
}
 
#[derive(Debug, Deserialize)]
struct UpdateTodo {
    title: Option<String>,
    completed: Option<bool>,
}
 
async fn list_todos(State(state): State<Arc<AppState>>) -> Result<Json<Vec<Todo>>, AppError> {
    let todos = sqlx::query_as::<_, Todo>("SELECT id, title, completed FROM todos")
        .fetch_all(&state.db)
        .await?;
    Ok(Json(todos))
}
 
async fn create_todo(
    State(state): State<Arc<AppState>>,
    Json(input): Json<CreateTodo>,
) -> Result<(StatusCode, Json<Todo>), AppError> {
    let todo = sqlx::query_as::<_, Todo>(
        "INSERT INTO todos (id, title, completed) VALUES ($1, $2, false)
         RETURNING id, title, completed",
    )
    .bind(Uuid::new_v4())
    .bind(input.title)
    .fetch_one(&state.db)
    .await?;
    Ok((StatusCode::CREATED, Json(todo)))
}
 
async fn get_todo(
    State(state): State<Arc<AppState>>,
    Path(id): Path<Uuid>,
) -> Result<Json<Todo>, AppError> {
    let todo = sqlx::query_as::<_, Todo>("SELECT id, title, completed FROM todos WHERE id = $1")
        .bind(id)
        .fetch_one(&state.db)
        .await?;
    Ok(Json(todo))
}
 
async fn update_todo(
    State(state): State<Arc<AppState>>,
    Path(id): Path<Uuid>,
    Json(input): Json<UpdateTodo>,
) -> Result<Json<Todo>, AppError> {
    let todo = sqlx::query_as::<_, Todo>(
        "UPDATE todos
         SET title = COALESCE($2, title),
             completed = COALESCE($3, completed)
         WHERE id = $1
         RETURNING id, title, completed",
    )
    .bind(id)
    .bind(input.title)
    .bind(input.completed)
    .fetch_one(&state.db)
    .await?;
    Ok(Json(todo))
}
 
async fn delete_todo(
    State(state): State<Arc<AppState>>,
    Path(id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
    let result = sqlx::query("DELETE FROM todos WHERE id = $1")
        .bind(id)
        .execute(&state.db)
        .await?;
    if result.rows_affected() == 0 {
        return Err(AppError::NotFound("Todo not found".into()));
    }
    Ok(StatusCode::NO_CONTENT)
}

Compile-time checked queries: SQLx also offers query! and query_as! macros that check your SQL against a real database while compiling. They need DATABASE_URL set at build time (or an offline cache generated with cargo sqlx prepare), and query_as! maps columns by name without needing FromRow. Swap them in once your schema is stable.


Authentication and Authorization

A common pattern in Axum is to use a custom extractor for authentication instead of middleware. This keeps your handlers clean and makes auth requirements visible in each handler's signature.

JWT Auth Extractor

jsonwebtoken 10 and later make you pick a crypto backend. rust_crypto is pure Rust; aws_lc_rs is the alternative. Without either, signing and verifying panic at runtime:

cargo add jsonwebtoken -F rust_crypto
use axum::{
    extract::FromRequestParts,
    http::{StatusCode, header::AUTHORIZATION, request::Parts},
};
use jsonwebtoken::{DecodingKey, Validation, decode};
use serde::{Deserialize, Serialize};
 
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
    sub: String,
    exp: usize,
}
 
struct AuthUser {
    user_id: String,
}
 
impl<S> FromRequestParts<S> for AuthUser
where
    S: Send + Sync,
{
    type Rejection = StatusCode;
 
    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let token = parts
            .headers
            .get(AUTHORIZATION)
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.strip_prefix("Bearer "))
            .ok_or(StatusCode::UNAUTHORIZED)?;
 
        // In production, load the secret from an environment variable
        let token_data = decode::<Claims>(
            token,
            &DecodingKey::from_secret(b"your-secret-key"),
            &Validation::default(),
        )
        .map_err(|_| StatusCode::UNAUTHORIZED)?;
 
        Ok(AuthUser {
            user_id: token_data.claims.sub,
        })
    }
}
 
// Usage - auth is enforced by the extractor's presence:
async fn my_profile(user: AuthUser) -> String {
    format!("Hello, user {}", user.user_id)
}
 
// No auth needed - just don't include the extractor:
async fn public_page() -> &'static str {
    "Anyone can see this"
}

Validation::default() checks the signature with HS256 and rejects expired tokens using the exp claim.

Optional Authentication with Option<T>

In Axum 0.7, Option<T> swallowed every rejection and gave you None. Axum 0.8 changed this. Option<T> now only works for extractors that implement OptionalFromRequestParts (or OptionalFromRequest), and that implementation decides which cases mean "absent" and which are still errors.

For AuthUser, a missing header should mean an anonymous visitor, but a bad token should still be rejected:

use axum::extract::OptionalFromRequestParts;
 
impl<S> OptionalFromRequestParts<S> for AuthUser
where
    S: Send + Sync,
{
    type Rejection = StatusCode;
 
    async fn from_request_parts(
        parts: &mut Parts,
        state: &S,
    ) -> Result<Option<Self>, Self::Rejection> {
        // No Authorization header: an anonymous visitor
        if !parts.headers.contains_key(AUTHORIZATION) {
            return Ok(None);
        }
        // A header is present, so it has to be valid
        <AuthUser as FromRequestParts<S>>::from_request_parts(parts, state)
            .await
            .map(Some)
    }
}
 
async fn feed(user: Option<AuthUser>) -> String {
    match user {
        Some(u) => format!("Personalized feed for {}", u.user_id),
        None => "Public feed".into(),
    }
}

Now a request with no Authorization header gets the public feed, and a request with an invalid token gets 401 Unauthorized. If you want the old "any failure becomes None" behaviour for an extractor, use Result<T, T::Rejection> as the argument and match on it.


Serving Static Files and SPAs

Use tower-http to serve static files alongside your API:

cargo add tower-http -F fs

Static File Server

Serve everything that isn't an API route from a directory. Because nesting at / panics in 0.8, the directory goes in fallback_service:

use tower_http::services::ServeDir;
 
let app = Router::new()
    .nest("/api", api_routes())
    .fallback_service(ServeDir::new("static"));

Single-Page Application (React, Vue, etc.)

For SPAs, unknown paths should return index.html so the client-side router can handle them:

use tower_http::services::{ServeDir, ServeFile};
 
let app = Router::new()
    .nest("/api", api_routes())
    .fallback_service(
        ServeDir::new("dist").fallback(ServeFile::new("dist/index.html")),
    );

Use .fallback(...) here rather than .not_found_service(...). Both serve index.html, but not_found_service always sets the status to 404 Not Found, so every deep link into your app would look like an error to crawlers and uptime checks. fallback keeps the 200 OK from ServeFile.


WebSockets

Axum has first-class WebSocket support behind the ws feature:

cargo add axum -F ws
use axum::{
    extract::ws::{WebSocket, WebSocketUpgrade},
    response::Response,
};
 
async fn ws_handler(ws: WebSocketUpgrade) -> Response {
    ws.on_upgrade(handle_socket)
}
 
async fn handle_socket(mut socket: WebSocket) {
    while let Some(Ok(msg)) = socket.recv().await {
        // Echo messages back
        if socket.send(msg).await.is_err() {
            break;
        }
    }
}
 
// Route:
// .route("/ws", get(ws_handler))

Note: In 0.8, text and binary messages carry Utf8Bytes and Bytes instead of String and Vec<u8>. Build them with Message::Text("hi".into()), and call .as_str() on received text.


Putting It All Together

All the pieces above - routing, extractors, state, error handling, middleware - compose naturally. Here is a complete todo API you can run as-is. To keep it runnable without a database it stores todos in memory. Swap the RwLock<Vec<Todo>> for the SQLx handlers above when you're ready.

Dependencies:

cargo add axum@0.8 tokio -F tokio/full
cargo add serde -F derive
cargo add serde_json tracing tracing-subscriber
cargo add uuid -F v4,serde
cargo add tower-http -F cors,trace

src/main.rs:

use std::sync::Arc;
 
use axum::{
    Json, Router,
    extract::{Path, Request, State},
    http::StatusCode,
    middleware::{self, Next},
    response::{IntoResponse, Response},
    routing::get,
};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use uuid::Uuid;
 
#[derive(Clone, Serialize)]
struct Todo {
    id: Uuid,
    title: String,
    completed: bool,
}
 
#[derive(Deserialize)]
struct CreateTodo {
    title: String,
}
 
#[derive(Deserialize)]
struct UpdateTodo {
    title: Option<String>,
    completed: Option<bool>,
}
 
struct AppState {
    todos: RwLock<Vec<Todo>>,
}
 
enum AppError {
    NotFound,
}
 
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        match self {
            AppError::NotFound => (
                StatusCode::NOT_FOUND,
                Json(serde_json::json!({ "error": "Todo not found" })),
            )
                .into_response(),
        }
    }
}
 
async fn health() -> &'static str {
    "OK"
}
 
async fn list_todos(State(state): State<Arc<AppState>>) -> Json<Vec<Todo>> {
    Json(state.todos.read().await.clone())
}
 
async fn create_todo(
    State(state): State<Arc<AppState>>,
    Json(input): Json<CreateTodo>,
) -> (StatusCode, Json<Todo>) {
    let todo = Todo {
        id: Uuid::new_v4(),
        title: input.title,
        completed: false,
    };
    state.todos.write().await.push(todo.clone());
    (StatusCode::CREATED, Json(todo))
}
 
async fn get_todo(
    State(state): State<Arc<AppState>>,
    Path(id): Path<Uuid>,
) -> Result<Json<Todo>, AppError> {
    let todos = state.todos.read().await;
    let todo = todos.iter().find(|t| t.id == id).ok_or(AppError::NotFound)?;
    Ok(Json(todo.clone()))
}
 
async fn update_todo(
    State(state): State<Arc<AppState>>,
    Path(id): Path<Uuid>,
    Json(input): Json<UpdateTodo>,
) -> Result<Json<Todo>, AppError> {
    let mut todos = state.todos.write().await;
    let todo = todos
        .iter_mut()
        .find(|t| t.id == id)
        .ok_or(AppError::NotFound)?;
    if let Some(title) = input.title {
        todo.title = title;
    }
    if let Some(completed) = input.completed {
        todo.completed = completed;
    }
    Ok(Json(todo.clone()))
}
 
async fn delete_todo(
    State(state): State<Arc<AppState>>,
    Path(id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
    let mut todos = state.todos.write().await;
    let len = todos.len();
    todos.retain(|t| t.id != id);
    if todos.len() == len {
        return Err(AppError::NotFound);
    }
    Ok(StatusCode::NO_CONTENT)
}
 
async fn log_request(req: Request, next: Next) -> Response {
    let method = req.method().clone();
    let uri = req.uri().clone();
    let response = next.run(req).await;
    tracing::info!("{method} {uri} -> {}", response.status());
    response
}
 
fn app(state: Arc<AppState>) -> Router {
    Router::new()
        .route("/health", get(health))
        .route("/todos", get(list_todos).post(create_todo))
        .route(
            "/todos/{id}",
            get(get_todo).put(update_todo).delete(delete_todo),
        )
        .layer(middleware::from_fn(log_request))
        .layer(TraceLayer::new_for_http())
        .layer(CorsLayer::permissive())
        .with_state(state)
}
 
#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();
 
    let state = Arc::new(AppState {
        todos: RwLock::new(Vec::new()),
    });
 
    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(state)).await.unwrap();
}

Each handler uses the patterns we covered - State for shared data, Path and Json extractors, and Result<T, AppError> for error handling. Tower layers handle cross-cutting concerns.

Test it with curl:

# Health check
curl http://localhost:3000/health
 
# Create a todo
curl -X POST http://localhost:3000/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Learn Axum"}'
 
# List todos
curl http://localhost:3000/todos
 
# Update a todo (replace <uuid> with an id from the list)
curl -X PUT http://localhost:3000/todos/<uuid> \
  -H "Content-Type: application/json" \
  -d '{"completed":true}'
 
# Delete a todo
curl -X DELETE http://localhost:3000/todos/<uuid>


Testing Without a Running Server

Axum routers are Tower services, so you can test them directly with oneshot - no HTTP server needed. These tests go at the bottom of the src/main.rs from the previous section:

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        body::Body,
        http::{Request, StatusCode},
    };
    use http_body_util::BodyExt;
    use tower::ServiceExt;
 
    #[tokio::test]
    async fn test_health_check() {
        let state = Arc::new(AppState {
            todos: RwLock::new(Vec::new()),
        });
        let app = app(state);
 
        let response = app
            .oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
            .await
            .unwrap();
 
        assert_eq!(response.status(), StatusCode::OK);
 
        let body = response.into_body().collect().await.unwrap().to_bytes();
        assert_eq!(&body[..], b"OK");
    }
 
    #[tokio::test]
    async fn test_create_and_list_todos() {
        let state = Arc::new(AppState {
            todos: RwLock::new(Vec::new()),
        });
 
        // Create a todo
        let response = app(state.clone())
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/todos")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"title":"Test todo"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
 
        assert_eq!(response.status(), StatusCode::CREATED);
 
        // List todos
        let response = app(state)
            .oneshot(Request::builder().uri("/todos").body(Body::empty()).unwrap())
            .await
            .unwrap();
 
        assert_eq!(response.status(), StatusCode::OK);
        let body = response.into_body().collect().await.unwrap().to_bytes();
        let todos: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
        assert_eq!(todos.len(), 1);
        assert_eq!(todos[0]["title"], "Test todo");
    }
}

Add to [dev-dependencies]:

tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"

Project Structure for Large Applications

As your API grows, organize by feature:

src/
โ”œโ”€โ”€ main.rs        # Server setup
โ”œโ”€โ”€ config.rs      # Configuration
โ”œโ”€โ”€ error.rs       # AppError enum
โ”œโ”€โ”€ state.rs       # AppState
โ”œโ”€โ”€ routes/
โ”‚   โ”œโ”€โ”€ mod.rs     # Route modules
โ”‚   โ”œโ”€โ”€ users.rs   # User handlers
โ”‚   โ”œโ”€โ”€ todos.rs   # Todo handlers
โ”‚   โ””โ”€โ”€ auth.rs    # Auth handlers
โ”œโ”€โ”€ extractors/
โ”‚   โ”œโ”€โ”€ mod.rs
โ”‚   โ””โ”€โ”€ auth.rs    # AuthUser
โ”œโ”€โ”€ middleware/
โ”‚   โ”œโ”€โ”€ mod.rs
โ”‚   โ”œโ”€โ”€ timing.rs  # Request timing
โ”‚   โ””โ”€โ”€ logging.rs # Custom logging
โ””โ”€โ”€ models/
    โ”œโ”€โ”€ mod.rs
    โ”œโ”€โ”€ user.rs    # User + DB queries
    โ””โ”€โ”€ todo.rs    # Todo + DB queries
migrations/
โ”œโ”€โ”€ 001_create_users.sql
โ””โ”€โ”€ 002_create_todos.sql

Each route module exports a function that returns a Router:

// src/routes/users.rs
use axum::{Router, routing::get};
 
pub fn router() -> Router<Arc<AppState>> {
    Router::new()
        .route("/users", get(list).post(create))
        .route("/users/{id}", get(show).put(update).delete(destroy))
}
 
// src/routes/mod.rs
pub fn all_routes() -> Router<Arc<AppState>> {
    Router::new()
        .merge(users::router())
        .merge(todos::router())
        .merge(auth::router())
}
 
// src/main.rs
let app = routes::all_routes()
    .layer(TraceLayer::new_for_http())
    .with_state(state);

The Router<Arc<AppState>> type says "this router still needs an Arc<AppState>". Calling .with_state(state) once at the top supplies it and turns the whole tree into a plain Router.


Deployment

Graceful Shutdown

Production servers need graceful shutdown:

use tokio::signal;
 
#[tokio::main]
async fn main() {
    // ... build your app ...
 
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
 
    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await
        .unwrap();
}
 
async fn shutdown_signal() {
    let ctrl_c = async {
        signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
    };
 
    #[cfg(unix)]
    let terminate = async {
        signal::unix::signal(signal::unix::SignalKind::terminate())
            .expect("Failed to listen for SIGTERM")
            .recv()
            .await;
    };
 
    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();
 
    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }
 
    tracing::info!("Shutting down gracefully...");
}

Once the signal fires, axum::serve stops accepting new connections and waits for in-flight requests to finish before returning.


Axum vs Actix Web vs Rocket

Here's an honest comparison as of 2026:

Axum 0.8Actix Web 4Rocket 0.5
PerformanceExcellentExcellent, often slightly ahead in synthetic benchmarksGood
MiddlewareTower ecosystem (huge)Own system (mature)Fairings (built-in)
RoutingPlain functions, no macrosAttribute macros or builder APIAttribute macros
Async runtimeTokioTokio, via actix-rtTokio
Learning curveModerateModerateLow (batteries included)
EcosystemLargest and growing fastMatureSmaller
Release cadenceRegular 0.8.x releasesRegular 4.x releasesNo release since 0.5.1 (May 2024)

Choose Axum for: Most applications, Tower ecosystem compatibility, type-safe design, and Tokio integration.

Choose Actix Web for: Teams already invested in it, or workloads where its benchmark lead matters to you (measure your own workload first - the gap is small for typical apps).

Choose Rocket for: Rapid prototyping and small projects where its batteries-included developer experience matters more than release activity.

For new projects in 2026, Axum is the default choice in the Rust ecosystem. Its adoption, its place in the Tokio project and its middleware compatibility make it the safest long-term bet.


FAQ

What version of Axum should I use?

Use Axum 0.8.x (0.8.9 at the time of writing) - the current release line with the {param} path syntax, native async traits for extractors, and OptionalFromRequestParts.

Is Axum 1.0 out?

No. Axum is still on 0.x, so minor version bumps (0.7 to 0.8) can contain breaking changes. Patch releases within 0.8 are backwards compatible.

Do I need #[async_trait] for custom extractors?

No. Axum 0.8 uses Rust's native async fn in traits. Remove #[async_trait] from all FromRequest and FromRequestParts implementations when you upgrade.

How do I handle CORS?

Use tower-http with the cors feature:

use tower_http::cors::CorsLayer;
 
let app = Router::new()
    .route("/api/data", get(handler))
    .layer(CorsLayer::permissive()); // or configure specific origins

Can I use Axum with GraphQL?

Yes. The async-graphql crate has Axum 0.8 integration via async-graphql-axum.

How do I add request validation?

Use the validator crate inside a custom extractor that deserializes and then validates the input. For nicer error messages when built-in extractors reject a request, axum-extra provides WithRejection.

Is Axum production-ready?

Yes. Despite the 0.x version number, Axum is widely used in production and is maintained under the Tokio project. Pin a minor version (axum = "0.8") and read the changelog before moving to the next one.

How do I generate OpenAPI docs?

Use the utoipa crate. Its derive macros generate OpenAPI specs from your types and handlers, and utoipa-axum registers routes and their docs in one place.


What's Next?

You now have everything you need to build Rust APIs with Axum. Here are some next steps:

  • Add a database - Replace the in-memory store with the SQLx handlers shown above.
  • Add authentication - Build the JWT extractor from the auth section.
  • Generate OpenAPI docs - Use utoipa to auto-generate Swagger documentation.
  • Practice - Try the Rust challenges on Rustfinity to sharpen your skills.

Axum's combination of type safety, Tower compatibility, and macro-free API design makes it an excellent choice for Rust web development in 2026. Start building.

Subscribe to our newsletter

Get the latest updates on courses, features, tools, and resources about Rust.

Ferris the Rust crab

Learn Rust by Practice

Master Rust through hands-on coding exercises and real-world examples.

Get Started