Axum Routing & Handlers
Define HTTP routes, bind handlers to methods, and return typed responses with Axum 0.8.
Recipe
Quick-reference recipe card - copy-paste ready.
use axum::{routing::{get, post, delete}, Router, Json};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)] struct CreateUser { email: String }
#[derive(Serialize)] struct User { id: u64, email: String }
async fn create(Json(body): Json<CreateUser>) -> Json<User> {
Json(User { id: 1, email: body.email })
}
let app = Router::new()
.route("/users", get(list_users).post(create))
.route("/users/:id", get(get_user).delete(delete_user));When to reach for this: You need explicit HTTP method routing with typed request and response bodies.
Working Example
use axum::{
Router, extract::{Path, State},
http::StatusCode,
routing::{delete, get, post},
Json,
};
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize)]
struct Item { id: u64, name: String }
#[derive(Deserialize)]
struct NewItem { name: String }
type Store = std::sync::Arc<tokio::sync::RwLock<Vec<Item>>>;
async fn list(State(store): State<Store>) -> Json<Vec<Item>> {
Json(store.read().await.clone())
}
async fn create(
State(store): State<Store>,
Json(body): Json<NewItem>,
) -> (StatusCode, Json<Item>) {
let mut guard = store.write().await;
let item = Item { id: guard.len() as u64 + 1, name: body.name };
guard.push(item.clone());
(StatusCode::CREATED, Json(item))
}
async fn remove(Path(id): Path<u64>, State(store): State<Store>) -> StatusCode {
let mut guard = store.write().await;
if let Some(pos) = guard.iter().position(|i| i.id == id) {
guard.remove(pos);
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
#[tokio::main]
async fn main() {
let store = Store::default();
let app = Router::new()
.route("/items", get(list).post(create))
.route("/items/:id", delete(remove))
.with_state(store);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}What this demonstrates:
- REST-style routes with method-specific handlers on the same path.
- Status-code tuples like
(StatusCode::CREATED, Json(item))for precise responses. PathandStateextractors combined in one handler signature.- In-memory
RwLockstore for a runnable demo without a database.
Deep Dive
How It Works
- A
Routeris atower::Servicethat matches requests by path and method. - Handlers are async functions whose last parameters are extractors; Axum runs extractors in parallel when possible.
- Return types implement
IntoResponse- strings, tuples,Json,StatusCode, or custom types.
Route Patterns
| Pattern | Example | Notes |
|---|---|---|
| Static | /health | Exact match |
| Param | /users/:id | Captured as Path<T> |
| Wildcard | /files/*path | Remaining segments as one param |
| Fallback | .fallback(handler) | 404 or SPA shell |
Rust Notes
use axum::response::Redirect;
async fn old() -> Redirect { Redirect::permanent("/v2/old") }Gotchas
- Route order conflicts - Overlapping static and param routes can match unexpectedly. Fix: Register static paths like
/users/mebefore/users/:id. - Missing state - Handlers using
Statewithout.with_state()panic at runtime. Fix: Always call.with_state()on the final router before serving. - Blocking in async handlers - CPU-heavy or blocking I/O stalls the Tokio worker. Fix: Use
tokio::task::spawn_blockingor async database drivers. - Wrong method returns 405 - Clients POST to a GET-only route. Fix: Document allowed methods; return
Allowheader in custom 405 handlers if needed. - Serde field mismatch - JSON keys do not match struct fields. Fix: Add
#[serde(rename = "camelCase")]or return explicit 422 mapping.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Actix-web | Team prefers actor model or mature ecosystem | You want Tower ecosystem composability |
| Rocket | Procedural macros and compile-time routing appeal | You need minimal magic and stable async traits |
| hyper directly | Building a custom HTTP stack | Standard REST APIs with routing and extractors |
FAQs
How do I add a 404 handler?
Use .fallback(handler) on the router.
Can handlers be sync?
Yes with async fn wrapping sync code via spawn_blocking, but prefer async I/O.
How to version APIs?
Nest under /v1 with Router::nest.
Multiple routers?
Merge with Router::merge or nest under prefixes.
OpenAPI docs?
Add utoipa or aide - not built into Axum.
HTTPS termination?
Usually at the load balancer; Axum serves HTTP behind a TLS proxy.
Path vs Query for filters?
Path for resource identity; query for optional filters and pagination.
Custom response headers?
Return (StatusCode, [(HeaderName, &str)], Json(body)).
File downloads?
Use axum::body::Body or tower_http::services::ServeDir.
Testing routes?
Use tower::ServiceExt::oneshot without binding a port.
CORS?
Add tower_http::cors::CorsLayer.
Rate limits?
Apply tower_governor or a custom Tower layer.
Related
- Web Backends Basics - First server setup
- Extractors & State - Shared state and extractors
- Error Handling in Handlers - Typed errors
- Middleware & Tower - Cross-cutting layers
- Testing Web Services - Route tests
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.