3 min read
On this page

Routing & Handlers

Axum is a web framework built on top of Tower and hyper that takes full advantage of Rust's type system. Unlike frameworks in other languages where routing is configured through decorators or string matching, Axum uses the type system itself to map HTTP requests to handler functions. The result is a framework where invalid routes and incorrect handler signatures are caught at compile time.

Router Basics

Everything starts with Router::new(). You attach routes by calling method handlers:

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

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(root))
        .route("/health", get(health_check));

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

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

async fn root() -> &'static str {
    "Hello, World!"
}

async fn health_check() -> &'static str {
    "OK"
}

Each route maps a path to a handler function. The get, post, put, delete, and patch functions from axum::routing specify the HTTP method.

HTTP Methods on a Single Path

You can chain multiple methods on the same path using method_router:

use axum::{Router, routing::{get, post, put, delete}};

let app = Router::new()
    .route("/users", get(list_users).post(create_user))
    .route("/users/:id", get(get_user).put(update_user).delete(delete_user));

This is idiomatic Axum. One path, multiple methods, each pointing to a different handler.

Handler Function Signatures

Here is where Axum gets interesting. A handler is any async function that takes zero or more extractors as arguments and returns something that implements IntoResponse. The framework figures out how to call your function by inspecting its type signature.

use axum::extract::{Path, Query, Json};
use serde::Deserialize;

// No arguments — handles requests that need no input
async fn list_users() -> String {
    "All users".to_string()
}

// One extractor — pulls the `id` from the URL path
async fn get_user(Path(id): Path<u64>) -> String {
    format!("User {}", id)
}

// Multiple extractors — path param and JSON body
async fn update_user(
    Path(id): Path<u64>,
    Json(payload): Json<UpdateUser>,
) -> String {
    format!("Updated user {} with name {}", id, payload.name)
}

#[derive(Deserialize)]
struct UpdateUser {
    name: String,
}

The key insight: extractors are just function parameters. Axum resolves them from the HTTP request automatically. The order matters only for the body extractor, which must come last since it consumes the request body.

Path Extractors

Path parameters use :name syntax in the route and Path in the handler:

use axum::extract::Path;

// Single parameter
async fn get_article(Path(slug): Path<String>) -> String {
    format!("Article: {}", slug)
}

// Multiple parameters via tuple
async fn get_comment(
    Path((post_id, comment_id)): Path<(u64, u64)>,
) -> String {
    format!("Post {} Comment {}", post_id, comment_id)
}

// Or use a struct for named parameters
#[derive(Deserialize)]
struct CommentPath {
    post_id: u64,
    comment_id: u64,
}

async fn get_comment_v2(Path(params): Path<CommentPath>) -> String {
    format!("Post {} Comment {}", params.post_id, params.comment_id)
}

The struct approach is better when you have more than two parameters. Names must match the route placeholders.

Query Extractors

Query strings are deserialized into a struct:

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 {} with {} items", page, per_page)
}

A request to /items?page=2&per_page=50 will deserialize into the struct. Missing fields work because they are Option.

JSON Request & Response

Use Json for both input and output:

use axum::Json;
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct CreateUser {
    username: String,
    email: String,
}

#[derive(Serialize)]
struct User {
    id: u64,
    username: String,
    email: String,
}

async fn create_user(Json(payload): Json<CreateUser>) -> Json<User> {
    let user = User {
        id: 1,
        username: payload.username,
        email: payload.email,
    };
    Json(user)
}

Json<T> as a parameter deserializes the request body. Json<T> as a return type serializes to JSON and sets the Content-Type header. Serde does the heavy lifting.

Response Types

Anything implementing IntoResponse can be returned from a handler:

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

// String — 200 OK with text/plain
async fn plain() -> String {
    "Hello".to_string()
}

// Status code — just the code, no body
async fn no_content() -> StatusCode {
    StatusCode::NO_CONTENT
}

// Tuple — status code + body
async fn created() -> (StatusCode, String) {
    (StatusCode::CREATED, "Created".to_string())
}

// Tuple with headers
async fn with_headers() -> (StatusCode, [(&'static str, &'static str); 1], String) {
    (
        StatusCode::OK,
        [("X-Custom", "value")],
        "With header".to_string(),
    )
}

// Result for fallible handlers
async fn maybe_fail() -> Result<String, StatusCode> {
    Ok("Success".to_string())
}

The tuple approach is the most practical. Status code first, optional headers, then the body. This covers the vast majority of use cases.

Nesting Routers

Large applications split routes across modules using nest:

use axum::Router;

fn user_routes() -> Router {
    Router::new()
        .route("/", get(list_users).post(create_user))
        .route("/:id", get(get_user).put(update_user).delete(delete_user))
}

fn article_routes() -> Router {
    Router::new()
        .route("/", get(list_articles).post(create_article))
        .route("/:slug", get(get_article))
}

let app = Router::new()
    .nest("/api/users", user_routes())
    .nest("/api/articles", article_routes());

Each nested router handles its own subtree. The prefix is automatically prepended. This keeps route definitions close to the handlers they serve.

Fallback Handlers

Handle requests that do not match any route:

use axum::http::StatusCode;

let app = Router::new()
    .route("/", get(root))
    .fallback(not_found);

async fn not_found() -> (StatusCode, &'static str) {
    (StatusCode::NOT_FOUND, "Nothing here")
}

Without a fallback, Axum returns a plain 404. With one, you control the response.

Common Pitfalls

  • Forgetting that the body extractor must be last. If you put Json before Path in the function signature, you will get confusing compile errors. The body can only be consumed once, so its extractor goes last.
  • Using String where you need Json. Returning a String sets the content type to text/plain. If your client expects JSON, use Json<T>.
  • Mismatched path parameter names. When using a struct with Path, the struct field names must match the route placeholders exactly. A mismatch gives you a runtime 400, not a compile error.
  • Not making handler functions async. Axum handlers must be async. Non-async functions will not satisfy the trait bounds and you will get a cryptic error.
  • Blocking in async handlers. CPU-intensive work blocks the tokio runtime. Use tokio::task::spawn_blocking for heavy computation.

Key Takeaways

  • Axum routes are defined with Router::new() and method functions like get, post, put, delete.
  • Handlers are async functions. Their parameters are extractors that Axum resolves from the HTTP request.
  • Path, Query, and Json are the three extractors you will use constantly.
  • The type system enforces correctness: wrong extractor types fail at compile time.
  • Nest routers to organize large applications into modular route trees.
  • Anything implementing IntoResponse can be returned, with tuples being the most flexible option.