rustaxumapibackendtutorial

Axum Tutorial: Build a REST API in Rust (Complete Guide 2026)

ยท20 min read

Axum Tutorial: Build a REST API in Rust (Complete Guide 2026)

Axum has become the most popular Rust web framework. Backed by the Tokio team, it 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 where you should start.

This guide walks you through everything you need to know - from your first route to a production-ready CRUD API. Every code example in this article compiles and runs against Axum 0.8 (the latest stable release as of March 2026).

Table of Contents


Why Axum?

Before we write any code, here's why Axum has overtaken every other Rust web framework in adoption:

  1. No routing macros required. Routes are plain Rust functions. No #[get("/")] decorators, no magic - just types and functions.
  2. Tower compatibility. Every Tower middleware ever written works with Axum out of the box. Rate limiting, tracing, compression, CORS - plug it in.
  3. Backed by Tokio. Axum is maintained by the same team that builds the Rust async runtime that powers most of the ecosystem.
  4. Type-safe extractors. The compiler tells you when you're doing it wrong. Path parameters, query strings, JSON bodies - they're all checked at compile time.
  5. Best-in-class error messages. Axum 0.8 introduced improved compiler diagnostics that tell you exactly what's wrong when a handler doesn't satisfy trait bounds.

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] needed)
WebSocket supportBuilt-in
HTTP/2 supportVia feature flag
OpenAPI generationVia utoipa crate

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@1 -F tokio/full

Replace src/main.rs:

use axum::{routing::get, Router};
 
#[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, 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::{routing::get, Router};
 
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, post};
 
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}, aligning with OpenAPI and Rust's format!() macro:

// Axum 0.8+ syntax
.route("/users/{id}", get(get_user))
.route("/files/{*path}", get(serve_file))  // wildcard
 
// Old syntax (pre-0.8) - no longer works:
// .route("/users/:id", get(get_user))

Migration note: If you're upgrading from Axum 0.7, search and replace :param with {param} and *param with {*param} across all your route definitions.

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.

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 })))
}

Custom Response Types with IntoResponse

For production APIs, define an enum for your responses:

use axum::{response::{IntoResponse, Response}, http::StatusCode, Json};
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.

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)
}

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::extract::{Path, Query, State, Json};
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, thanks to Rust's native async trait support:

use axum::{
    extract::FromRequestParts,
    http::{request::Parts, StatusCode},
};
 
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.

Basic State with Arc

use axum::{extract::State, routing::get, Router};
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 * FROM users")
        .fetch_all(&state.db_pool)
        .await
        .unwrap();
    Json(users)
}

Substates for Access Control

Limit what specific routes can access:

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

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::{response::{IntoResponse, Response}, http::StatusCode, Json};
 
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 * FROM users WHERE id = $1", 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. Every Tower middleware and tower-http layer works out of the box.

Built-in Tower-HTTP Layers

use tower_http::{
    cors::CorsLayer,
    compression::CompressionLayer,
    trace::TraceLayer,
    timeout::TimeoutLayer,
    limit::RequestBodyLimitLayer,
};
use std::time::Duration;
 
let app = Router::new()
    .route("/", get(index))
    .layer(TraceLayer::new_for_http())
    .layer(CompressionLayer::new())
    .layer(CorsLayer::permissive())
    .layer(TimeoutLayer::new(Duration::from_secs(30)))
    .layer(RequestBodyLimitLayer::new(1024 * 1024)); // 1MB limit

Layer order matters. Layers wrap from bottom to top. The last .layer() call is the outermost layer (runs first on request, last on response).

Writing Custom Middleware

use axum::{http::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::State;
 
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

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.8", features = ["runtime-tokio", "postgres", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Setting Up the Connection Pool

use sqlx::postgres::PgPoolOptions;
 
#[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
    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

use axum::extract::{Path, State, Json};
use axum::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 *",
        Uuid::new_v4(),
        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 * FROM todos WHERE id = $1", 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,
        r#"UPDATE todos
           SET title = COALESCE($2, title),
               completed = COALESCE($3, completed)
           WHERE id = $1
           RETURNING *"#,
        id,
        input.title,
        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> {
    sqlx::query!("DELETE FROM todos WHERE id = $1", id)
        .execute(&state.db)
        .await?;
    Ok(StatusCode::NO_CONTENT)
}

Authentication and Authorization

The recommended pattern in Axum is to use a custom extractor for authentication, not middleware. This keeps your handlers clean and makes auth testable.

JWT Auth Extractor

use axum::{
    extract::FromRequestParts,
    http::{request::Parts, StatusCode},
};
use jsonwebtoken::{decode, DecodingKey, Validation};
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 auth_header = 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>(
            auth_header,
            &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"
}

Optional Authentication with Option<T>

Wrap any extractor in Option<T> to make it optional. If extraction fails for any reason, the handler receives None instead of a rejection:

async fn feed(user: Option<AuthUser>) -> String {
    match user {
        Some(u) => format!("Personalized feed for {}", u.user_id),
        None => "Public feed".into(),
    }
}

Note: Option<AuthUser> returns None for all extraction failures - whether the token is missing or invalid. If you need to distinguish between "no credentials" and "bad credentials," handle that logic inside a custom extractor that returns Ok(None) for missing credentials and Err(...) for invalid ones.


Serving Static Files and SPAs

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

cargo add tower-http -F fs

Static File Server

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

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

For SPAs, you need a fallback to index.html for client-side routing:

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

WebSockets

Axum has first-class WebSocket support:

use axum::{
    extract::ws::{WebSocket, WebSocketUpgrade},
    response::IntoResponse,
};
 
async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
    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))

Tip: Enable the ws feature on axum: cargo add axum -F ws


Testing Without a Running Server

Axum routes are Tower services, so you can test them directly with oneshot - no HTTP server needed:

#[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
โ”œโ”€โ”€ lib.rs         # Shared types
โ”œโ”€โ”€ 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::{routing::get, Router};
 
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);

Deployment

Standard Deployment (Docker)

# Build stage
FROM rust:1.85 AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
 
# Runtime stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/axum-api /usr/local/bin/
EXPOSE 3000
CMD ["axum-api"]

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...");
}

Axum vs Actix Web vs Rocket

Here's an honest comparison as of 2026:

Axum 0.8Actix Web 4Rocket 0.5
PerformanceExcellentSlightly faster in benchmarksSlower under load
MemoryBest (standby + load)Good under load, higher standbyHigher under load
MiddlewareTower ecosystem (huge)Own system (mature)Fairings (built-in)
MacrosNone requiredNone requiredAttribute macros
AsyncNative TokioTokio-basedTokio-based
Learning curveModerateModerateLow (batteries included)
EcosystemGrowing fastMatureSmaller
Maintained byTokio teamCommunitySergio Benitez

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

Choose Actix Web for: Maximum raw throughput where every microsecond matters (financial systems, real-time analytics).

Choose Rocket for: Rapid prototyping, smaller projects, developer experience priority.

For new projects in 2026, Axum is the default choice for the Rust ecosystem. Its growth trajectory, team backing, and middleware compatibility make it the safest long-term bet.


FAQ

What version of Axum should I use?

Use Axum 0.8.x - the current stable release with the new {param} path syntax, native async traits, and improved error messages.

Do I need #[async_trait] for custom extractors?

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

How do I handle CORS?

Use tower-http:

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 excellent Axum integration via async-graphql-axum.

How do I add request validation?

Use the validator crate with a custom extractor, or use axum-extra's WithRejection for better error messages on extraction failures.

Is Axum production-ready?

Yes. Axum is stable, widely used in production by companies of all sizes, and backed by the Tokio team. Discord, for example, uses Tokio and Tower extensively in their Rust services.

How do I generate OpenAPI docs?

Use the utoipa crate. It provides derive macros that generate OpenAPI specs from your Axum types and handlers.


Complete Working Example

All the code in this article is based on a working project. Here's the full Cargo.toml and main.rs for a Todo CRUD API that you can run right now:

Cargo.toml:

[package]
name = "axum-api"
version = "0.1.0"
edition = "2024"
 
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "serde"] }

src/main.rs:

use axum::{
    extract::{Json, Path, Query, State},
    http::StatusCode,
    middleware,
    response::{IntoResponse, Response},
    routing::get,
    Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock};
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use uuid::Uuid;
 
#[derive(Debug, Clone, Serialize, Deserialize)]
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> }
 
#[derive(Debug, Deserialize)]
struct TodoQuery { completed: Option<bool> }
 
struct AppState { todos: RwLock<Vec<Todo>> }
 
enum ApiError { NotFound(String) }
 
impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        match self {
            ApiError::NotFound(msg) => (
                StatusCode::NOT_FOUND,
                Json(serde_json::json!({ "error": msg })),
            ).into_response(),
        }
    }
}
 
async fn log_request(
    req: axum::http::Request<axum::body::Body>,
    next: middleware::Next,
) -> Response {
    let method = req.method().clone();
    let uri = req.uri().clone();
    tracing::info!("--> {method} {uri}");
    let response = next.run(req).await;
    tracing::info!("<-- {} {method} {uri}", response.status());
    response
}
 
async fn health() -> &'static str { "OK" }
 
async fn list_todos(
    State(state): State<Arc<AppState>>,
    Query(query): Query<TodoQuery>,
) -> Json<Vec<Todo>> {
    let todos = state.todos.read().unwrap();
    let filtered: Vec<Todo> = todos
        .iter()
        .filter(|t| query.completed.map_or(true, |c| t.completed == c))
        .cloned()
        .collect();
    Json(filtered)
}
 
async fn get_todo(
    State(state): State<Arc<AppState>>,
    Path(id): Path<Uuid>,
) -> Result<Json<Todo>, ApiError> {
    let todos = state.todos.read().unwrap();
    todos.iter().find(|t| t.id == id).cloned().map(Json)
        .ok_or_else(|| ApiError::NotFound(format!("Todo {id} not found")))
}
 
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().unwrap().push(todo.clone());
    (StatusCode::CREATED, Json(todo))
}
 
async fn update_todo(
    State(state): State<Arc<AppState>>,
    Path(id): Path<Uuid>,
    Json(input): Json<UpdateTodo>,
) -> Result<Json<Todo>, ApiError> {
    let mut todos = state.todos.write().unwrap();
    let todo = todos.iter_mut().find(|t| t.id == id)
        .ok_or_else(|| ApiError::NotFound(format!("Todo {id} not found")))?;
    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, ApiError> {
    let mut todos = state.todos.write().unwrap();
    let len = todos.len();
    todos.retain(|t| t.id != id);
    if todos.len() == len {
        return Err(ApiError::NotFound(format!("Todo {id} not found")));
    }
    Ok(StatusCode::NO_CONTENT)
}
 
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()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "axum_api=debug,tower_http=debug".parse().unwrap()),
        )
        .init();
 
    let state = Arc::new(AppState { todos: RwLock::new(Vec::new()) });
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3030").await.unwrap();
    tracing::info!("Listening on {}", listener.local_addr().unwrap());
    axum::serve(listener, app(state)).await.unwrap();
}

Test it:

cargo run
 
# Health check
curl http://localhost:3030/health
# => OK
 
# Create a todo
curl -X POST http://localhost:3030/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Learn Axum"}'
# => {"id":"...","title":"Learn Axum","completed":false}
 
# List todos
curl http://localhost:3030/todos
# => [{"id":"...","title":"Learn Axum","completed":false}]
 
# Update a todo (replace UUID)
curl -X PUT http://localhost:3030/todos/<uuid> \
  -H "Content-Type: application/json" \
  -d '{"completed":true}'
 
# Filter by completed
curl "http://localhost:3030/todos?completed=true"
 
# Delete a todo
curl -X DELETE http://localhost:3030/todos/<uuid>
# => 204 No Content

What's Next?

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

  • Add a database - Integrate SQLx or Diesel with the patterns 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 zero-macro API design makes it the best choice for Rust web development in 2026. Start building.

Get updated on the latest courses, features, tools, resources about Rust, and more!

Learn Rust by Practice

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

Check out our blog

Discover more insightful articles and stay up-to-date with the latest trends.

Subscribe to our newsletter

Get the latest updates and exclusive content delivered straight to your inbox.