How do I build web applications with axum in Rust?
Walkthrough
The axum crate is an ergonomic web framework built on top of hyper, tokio, and tower. It provides a simple, intuitive API for building web applications with a focus on ergonomics and modularity. Axum uses Rust's type system to ensure correctness—handlers are just async functions that take extractors as parameters and return types that implement IntoResponse. It integrates seamlessly with the tower ecosystem for middleware and services.
Key concepts:
- Router — defines routes and their handlers
- Handlers — async functions that process requests
- Extractors — types that extract data from requests (Path, Query, Json, etc.)
- IntoResponse — trait for types that can be converted to HTTP responses
- State — shared application state passed to handlers
Code Example
# Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }use axum::{
routing::get,
Router,
};
async fn hello() -> &'static str {
"Hello, World!"
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(hello));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Server running at http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}Basic Routing
use axum::{
routing::{get, post, put, delete},
Router,
};
async fn home() -> &'static str {
"Welcome home!"
}
async fn users() -> &'static str {
"List of users"
}
async fn create_user() -> &'static str {
"User created"
}
async fn health() -> &'static str {
"OK"
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(home))
.route("/users", get(users).post(create_user))
.route("/health", get(health));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Path Parameters
use axum::{
extract::Path,
routing::get,
Router,
};
// Single path parameter
async fn get_user(Path(id): Path<u32>) -> String {
format!("User ID: {}", id)
}
// Multiple path parameters
async fn get_post(Path((user_id, post_id)): Path<(u32, u32)>) -> String {
format!("User {}'s post {}", user_id, post_id)
}
// Named path parameters with struct
#[derive(serde::Deserialize)]
struct PostPath {
user_id: u32,
post_id: u32,
}
async fn get_post_named(Path(params): Path<PostPath>) -> String {
format!("User {}'s post {}", params.user_id, params.post_id)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users/:id", get(get_user))
.route("/users/:user_id/posts/:post_id", get(get_post));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Query Parameters
use axum::{
extract::Query,
routing::get,
Router,
};
use serde::Deserialize;
#[derive(Deserialize)]
struct SearchParams {
q: String,
page: Option<u32>,
per_page: Option<u32>,
}
async fn search(Query(params): Query<SearchParams>) -> String {
let page = params.page.unwrap_or(1);
let per_page = params.per_page.unwrap_or(10);
format!("Searching for '{}' (page {}, {} per page)", params.q, page, per_page)
}
#[derive(Deserialize)]
struct FilterParams {
#[serde(default)]
active: bool,
#[serde(default = "default_sort")]
sort: String,
}
fn default_sort() -> String {
"created_at".to_string()
}
async fn list(Query(params): Query<FilterParams>) -> String {
format!("Active: {}, Sort by: {}", params.active, params.sort)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/search", get(search))
.route("/list", get(list));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}JSON Request and Response
use axum::{
extract::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
#[derive(Serialize)]
struct User {
id: u64,
name: String,
email: String,
}
async fn create_user(Json(payload): Json<CreateUser>) -> Json<User> {
Json(User {
id: 1,
name: payload.name,
email: payload.email,
})
}
#[derive(Serialize)]
struct ApiResponse<T> {
success: bool,
data: T,
}
async fn get_user() -> Json<ApiResponse<User>> {
Json(ApiResponse {
success: true,
data: User {
id: 1,
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
},
})
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users", post(create_user))
.route("/users/1", get(get_user));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Shared Application State
use axum::{
extract::State,
routing::{get, post},
Router,
};
use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Clone)]
struct AppState {
counter: Arc<RwLock<u32>>,
users: Arc<RwLock<HashMap<u32, User>>>,
}
#[derive(Clone, Serialize, Deserialize)]
struct User {
id: u32,
name: String,
}
async fn get_counter(State(state): State<AppState>) -> String {
let count = *state.counter.read().await;
format!("Counter: {}", count)
}
async fn increment_counter(State(state): State<AppState>) -> String {
let mut count = state.counter.write().await;
*count += 1;
format!("Counter incremented to: {}", *count)
}
async fn list_users(State(state): State<AppState>) -> axum::Json<Vec<User>> {
let users = state.users.read().await;
axum::Json(users.values().cloned().collect())
}
#[tokio::main]
async fn main() {
let state = AppState {
counter: Arc::new(RwLock::new(0)),
users: Arc::new(RwLock::new(HashMap::new())),
};
let app = Router::new()
.route("/counter", get(get_counter).post(increment_counter))
.route("/users", get(list_users))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Request Headers
use axum::{
http::{header, HeaderMap},
routing::get,
Router,
};
async fn get_headers(headers: HeaderMap) -> String {
let user_agent = headers
.get(header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.unwrap_or("Unknown");
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("Not specified");
format!("User-Agent: {}\nContent-Type: {}", user_agent, content_type)
}
// Typed header extraction
use axum::extract::TypedHeader;
use headers::UserAgent;
async fn get_user_agent(
TypedHeader(user_agent): TypedHeader<UserAgent>
) -> String {
format!("User-Agent: {}", user_agent)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/headers", get(get_headers))
.route("/user-agent", get(get_user_agent));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Building Responses
use axum::{
http::{StatusCode, header},
response::{IntoResponse, Response},
Json, Router,
routing::get,
};
use serde::Serialize;
#[derive(Serialize)]
struct ErrorResponse {
error: String,
code: u16,
}
// Custom response type
struct AppError(anyhow::Error);
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: self.0.to_string(),
code: 500,
}),
).into_response()
}
}
// Multiple response types
enum MyResponse {
Text(String),
Json(Vec<String>),
NotFound,
}
impl IntoResponse for MyResponse {
fn into_response(self) -> Response {
match self {
MyResponse::Text(s) => (
header::CONTENT_TYPE,
"text/plain",
s,
).into_response(),
MyResponse::Json(items) => Json(items).into_response(),
MyResponse::NotFound => StatusCode::NOT_FOUND.into_response(),
}
}
}
async fn custom_response() -> impl IntoResponse {
(
StatusCode::CREATED,
[(header::CONTENT_TYPE, "text/plain")],
"Created successfully!",
)
}
async fn error_response() -> Result<String, AppError> {
// Simulate an error
Err(AppError(anyhow::anyhow!("Something went wrong")))
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/custom", get(custom_response))
.route("/error", get(error_response));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Error Handling
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json, Router,
routing::get,
};
use serde::Serialize;
#[derive(Debug)]
enum ApiError {
NotFound,
BadRequest(String),
Internal(String),
}
#[derive(Serialize)]
struct ErrorBody {
error: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::NotFound => (StatusCode::NOT_FOUND, "Not found"),
ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.as_str()),
ApiError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.as_str()),
};
let body = Json(ErrorBody {
error: message.to_string(),
});
(status, body).into_response()
}
}
// Handler returning Result
async fn divide(a: i32, b: i32) -> Result<String, ApiError> {
if b == 0 {
return Err(ApiError::BadRequest("Cannot divide by zero".to_string()));
}
Ok(format!("{} / {} = {}", a, b, a / b))
}
// Using ? operator
async fn get_user_by_id(id: u32) -> Result<String, ApiError> {
if id == 0 {
return Err(ApiError::BadRequest("ID must be positive".to_string()));
}
// Simulate not found
if id > 100 {
return Err(ApiError::NotFound);
}
Ok(format!("User {}", id))
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/divide/:a/:b", get(divide))
.route("/users/:id", get(get_user_by_id));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Middleware with Tower
use axum::{
routing::get,
Router,
};
use tower::ServiceBuilder;
use tower_http::{
trace::TraceLayer,
cors::{CorsLayer, Any},
compression::CompressionLayer,
};
async fn handler() -> &'static str {
"Hello with middleware!"
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(handler))
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::new().allow_origin(Any))
.layer(CompressionLayer::new())
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Custom Middleware
use axum::{
routing::get,
Router,
};
use axum::http::{Request, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use std::time::Instant;
async fn logging_middleware<B>(
req: Request<B>,
next: Next<B>,
) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
let start = Instant::now();
println!("[REQUEST] {} {}", method, path);
let response = next.run(req).await;
let elapsed = start.elapsed();
println!("[RESPONSE] {} {} - {:?}", method, path, elapsed);
response
}
async fn auth_middleware<B>(
req: Request<B>,
next: Next<B>,
) -> Result<Response, StatusCode> {
let auth_header = req
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok());
match auth_header {
Some(token) if token == "Bearer secret-token" => {
Ok(next.run(req).await)
}
_ => Err(StatusCode::UNAUTHORIZED),
}
}
async fn public_handler() -> &'static str {
"Public endpoint"
}
async fn protected_handler() -> &'static str {
"Protected endpoint"
}
#[tokio::main]
async fn main() {
let protected_routes = Router::new()
.route("/protected", get(protected_handler))
.layer(axum::middleware::from_fn(auth_middleware));
let app = Router::new()
.route("/public", get(public_handler))
.merge(protected_routes)
.layer(axum::middleware::from_fn(logging_middleware));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Nested Routers
use axum::{
routing::get,
Router,
extract::Path,
};
async fn users_root() -> &'static str {
"Users API"
}
async fn get_user(Path(id): Path<u32>) -> String {
format!("User {}", id)
}
async fn products_root() -> &'static str {
"Products API"
}
async fn get_product(Path(id): Path<u32>) -> String {
format!("Product {}", id)
}
#[tokio::main]
async fn main() {
let users_router = Router::new()
.route("/", get(users_root))
.route("/:id", get(get_user));
let products_router = Router::new()
.route("/", get(products_root))
.route("/:id", get(get_product));
let app = Router::new()
.nest("/users", users_router)
.nest("/products", products_router);
// Routes:
// GET /users/ -> users_root
// GET /users/:id -> get_user
// GET /products/ -> products_root
// GET /products/:id -> get_product
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Form Data
use axum::{
extract::Form,
routing::{get, post},
Router,
response::Html,
};
use serde::Deserialize;
#[derive(Deserialize)]
struct LoginForm {
username: String,
password: String,
remember: Option<bool>,
}
async fn login_form() -> Html<&'static str> {
Html(r#"
<form method="post">
<input name="username" placeholder="Username" required>
<input name="password" type="password" placeholder="Password" required>
<label><input name="remember" type="checkbox"> Remember me</label>
<button type="submit">Login</button>
</form>
"#)
}
async fn handle_login(Form(form): Form<LoginForm>) -> String {
format!(
"Login attempt: {} (remember: {})",
form.username,
form.remember.unwrap_or(false)
)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/login", get(login_form).post(handle_login));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Static Files
use axum::{
routing::get,
Router,
};
use tower_http::services::ServeDir;
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(|| async { "Hello!" }))
.fallback_service(ServeDir::new("static"));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}WebSocket Support
use axum::{
extract::ws::{WebSocket, WebSocketUpgrade, Message},
routing::get,
Router,
};
async fn ws_handler(ws: WebSocketUpgrade) -> axum::response::Response {
ws.on_upgrade(handle_socket)
}
async fn handle_socket(mut socket: WebSocket) {
while let Some(msg) = socket.recv().await {
if let Ok(msg) = msg {
match msg {
Message::Text(text) => {
// Echo back
socket.send(Message::Text(text)).await.unwrap();
}
Message::Binary(data) => {
socket.send(Message::Binary(data)).await.unwrap();
}
Message::Close(_) => break,
_ => {}
}
} else {
break;
}
}
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/ws", get(ws_handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("WebSocket server at ws://127.0.0.1:3000/ws");
axum::serve(listener, app).await.unwrap();
}Streaming Responses
use axum::{
response::sse::{Event, Sse},
routing::get,
Router,
};
use std::convert::Infallible;
use tokio_stream::wrappers::IntervalStream;
use tokio_stream::StreamExt;
async fn sse_handler() -> Sse<impl futures_util::stream::Stream<Item = Result<Event, Infallible>>> {
let stream = IntervalStream::new(tokio::time::interval(
std::time::Duration::from_secs(1)
)).enumerate().map(|(i, _)| {
Event::default().data(format!("Message {}", i))
});
Sse::new(stream)
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/events", get(sse_handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("SSE endpoint at http://127.0.0.1:3000/events");
axum::serve(listener, app).await.unwrap();
}Real-World REST API Example
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{delete, get, post, put},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::Arc};
use tokio::sync::RwLock;
#[derive(Clone, Serialize, Deserialize)]
struct Todo {
id: u64,
title: String,
completed: bool,
}
#[derive(Deserialize)]
struct CreateTodo {
title: String,
}
#[derive(Deserialize)]
struct UpdateTodo {
title: Option<String>,
completed: Option<bool>,
}
type Db = Arc<RwLock<HashMap<u64, Todo>>>;
#[derive(Clone)]
struct AppState {
db: Db,
next_id: Arc<RwLock<u64>>,
}
async fn list_todos(State(state): State<AppState>) -> Json<Vec<Todo>> {
let db = state.db.read().await;
Json(db.values().cloned().collect())
}
async fn create_todo(
State(state): State<AppState>,
Json(payload): Json<CreateTodo>,
) -> (StatusCode, Json<Todo>) {
let mut id = state.next_id.write().await;
let todo_id = *id;
*id += 1;
let todo = Todo {
id: todo_id,
title: payload.title,
completed: false,
};
state.db.write().await.insert(todo_id, todo.clone());
(StatusCode::CREATED, Json(todo))
}
async fn get_todo(
State(state): State<AppState>,
Path(id): Path<u64>,
) -> Result<Json<Todo>, StatusCode> {
state.db.read().await
.get(&id)
.cloned()
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
async fn update_todo(
State(state): State<AppState>,
Path(id): Path<u64>,
Json(payload): Json<UpdateTodo>,
) -> Result<Json<Todo>, StatusCode> {
let mut db = state.db.write().await;
if let Some(todo) = db.get_mut(&id) {
if let Some(title) = payload.title {
todo.title = title;
}
if let Some(completed) = payload.completed {
todo.completed = completed;
}
Ok(Json(todo.clone()))
} else {
Err(StatusCode::NOT_FOUND)
}
}
async fn delete_todo(
State(state): State<AppState>,
Path(id): Path<u64>,
) -> StatusCode {
if state.db.write().await.remove(&id).is_some() {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
#[tokio::main]
async fn main() {
let state = AppState {
db: Arc::new(RwLock::new(HashMap::new())),
next_id: Arc::new(RwLock::new(1)),
};
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("127.0.0.1:3000").await.unwrap();
println!("Todo API at http://127.0.0.1:3000/todos");
axum::serve(listener, app).await.unwrap();
}Testing Axum Applications
use axum::{
routing::get,
Router,
http::StatusCode,
};
use tower::ServiceExt;
async fn hello() -> &'static str {
"Hello!"
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(hello));
// Test the handler
let response = app
.oneshot(axum::http::Request::builder().uri("/").body(()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
println!("Test passed!");
}Summary
- Handlers are async functions taking extractors and returning
impl IntoResponse Router::new().route(path, method(handler))defines routesPath<T>extracts path parametersQuery<T>extracts query parametersJson<T>handles JSON request/response bodiesState<T>provides shared application state- Use
with_state()to provide state to router layer()adds middleware via towernest()creates nested routesimpl IntoResponseallows flexible return types- Use tower-http for common middleware (CORS, logging, compression)
oneshot()enables easy testing- Perfect for: REST APIs, web services, real-time applications, microservices
