HTTP Servers with Axum: Routing, Middleware, and State
Axum is an HTTP framework built on Tower and Hyper. You need to know this because it explains the design decisions that will otherwise seem arbitrary. Tower is a library for building composable network clients and servers via the Service trait. Hyper is a low-level HTTP implementation. Axum is the ergonomic layer on top.
This architecture means Axum has essentially no invented middleware system — it uses Tower middleware, which means it works with the entire Tower ecosystem out of the box. It also means that when you hit a type error involving BoxError or Service<Request>, you know where to look.
The Minimal Server
[dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.7"
use axum::{routing::get, Router}; #[tokio::main] async fn main() { let app = Router::new() .route("/", get(root_handler)) .route("/health", get(health_handler)); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") .await .unwrap(); println!("Listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); } async fn root_handler() -> &'static str { "Hello, world" } async fn health_handler() -> &'static str { "OK" }
Axum 0.7 moved to axum::serve with a TcpListener. You'll see older examples using axum::Server::bind — that's Axum 0.6 and it no longer compiles.
Extractors: How Axum Reads Requests
This is the part worth understanding properly. Every argument to a handler function is an extractor — a type that implements FromRequest or FromRequestParts. Axum calls .extract() on each one before invoking your handler.
#![allow(unused)] fn main() { use axum::{ extract::{Path, Query, State}, http::StatusCode, response::Json, routing::get, Router, }; use serde::{Deserialize, Serialize}; #[derive(Deserialize)] struct PaginationParams { page: Option<u32>, per_page: Option<u32>, } #[derive(Serialize)] struct User { id: u32, name: String, } async fn get_user( Path(user_id): Path<u32>, Query(pagination): Query<PaginationParams>, ) -> Result<Json<User>, StatusCode> { // Path extracts from the URL path: /users/:id // Query extracts from query string: ?page=1&per_page=20 if user_id == 0 { return Err(StatusCode::NOT_FOUND); } Ok(Json(User { id: user_id, name: "Alice".to_string(), })) } }
FromRequest vs FromRequestParts
This distinction matters for the body:
FromRequestParts— can be extracted from the request without consuming the body. Path parameters, query strings, headers, state.FromRequest— needs the entire request, including body. JSON bodies, form data, raw bytes.
Because the body can only be read once, only one FromRequest extractor is allowed per handler. It must be the last argument. The compiler will tell you if you violate this.
#![allow(unused)] fn main() { use axum::extract::Json as JsonExtractor; // GOOD: Json<T> (body extractor) is last async fn create_user( State(db): State<DbPool>, JsonExtractor(payload): JsonExtractor<CreateUserRequest>, ) -> StatusCode { // ... StatusCode::CREATED } // COMPILE ERROR: two body extractors // async fn bad_handler( // JsonExtractor(a): JsonExtractor<Foo>, // JsonExtractor(b): JsonExtractor<Bar>, // ← ERROR // ) {} }
Rejections
When extraction fails, Axum returns a rejection. The built-in extractors have typed rejections that implement IntoResponse. You don't usually need to handle them explicitly — they automatically return appropriate HTTP error responses (400 for bad JSON, 404 for missing path segments, etc.).
But you can handle them:
#![allow(unused)] fn main() { use axum::{ extract::rejection::JsonRejection, response::{IntoResponse, Response}, }; async fn strict_handler( payload: Result<Json<MyRequest>, JsonRejection>, ) -> Response { match payload { Ok(Json(req)) => { // process req StatusCode::OK.into_response() } Err(rejection) => { // Custom error response (StatusCode::BAD_REQUEST, rejection.body_text()).into_response() } } } }
Shared State
Handlers often need access to shared resources: a database pool, configuration, an HTTP client. Axum's answer is State<T>.
use axum::extract::State; use std::sync::Arc; // Your application state #[derive(Clone)] struct AppState { db: sqlx::PgPool, config: Arc<Config>, } async fn get_items(State(state): State<AppState>) -> Json<Vec<String>> { // state.db, state.config are available here Json(vec!["item1".to_string(), "item2".to_string()]) } #[tokio::main] async fn main() { let state = AppState { db: create_pool().await, config: Arc::new(Config::from_env()), }; let app = Router::new() .route("/items", get(get_items)) .with_state(state); // ... }
AppState must implement Clone. If you have non-Clone things in your state, wrap them in Arc. The State extractor clones your state for each handler invocation — for things like database pools, cloning is cheap because the pool itself is reference-counted internally.
Multiple State Types
You can use FromRef to extract parts of your state:
#![allow(unused)] fn main() { use axum::extract::FromRef; #[derive(Clone)] struct AppState { db: sqlx::PgPool, jwt_secret: String, } // Allow handlers to extract just the pool impl FromRef<AppState> for sqlx::PgPool { fn from_ref(state: &AppState) -> Self { state.db.clone() } } // This handler only needs the pool async fn list_users(State(pool): State<sqlx::PgPool>) -> Json<Vec<User>> { // ... todo!() } }
Responses
Anything that implements IntoResponse can be returned from a handler. The standard types cover most cases:
#![allow(unused)] fn main() { use axum::{ http::{StatusCode, HeaderMap}, response::{Html, IntoResponse, Json, Redirect, Response}, }; // Tuple responses: (status, body) or (status, headers, body) async fn created() -> impl IntoResponse { (StatusCode::CREATED, Json(serde_json::json!({"id": 1}))) } async fn with_headers() -> impl IntoResponse { let mut headers = HeaderMap::new(); headers.insert("X-Request-Id", "abc123".parse().unwrap()); (StatusCode::OK, headers, "response body") } async fn redirect() -> Redirect { Redirect::to("/new-location") } async fn html_page() -> Html<String> { Html("<h1>Hello</h1>".to_string()) } }
Custom Response Types
Implement IntoResponse for your domain types:
#![allow(unused)] fn main() { use axum::response::{IntoResponse, Response}; use axum::http::StatusCode; use axum::Json; enum ApiError { NotFound(String), Unauthorized, Internal(anyhow::Error), } impl IntoResponse for ApiError { fn into_response(self) -> Response { let (status, message) = match self { ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), ApiError::Unauthorized => ( StatusCode::UNAUTHORIZED, "Unauthorized".to_string(), ), ApiError::Internal(err) => { tracing::error!("Internal error: {err:#}"); ( StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string(), ) } }; (status, Json(serde_json::json!({"error": message}))).into_response() } } // Now handlers can return Result<T, ApiError> async fn get_user_by_id( Path(id): Path<u32>, ) -> Result<Json<User>, ApiError> { if id == 0 { return Err(ApiError::NotFound(format!("User {id} not found"))); } Ok(Json(User { id, name: "Alice".to_string() })) } }
Middleware
Tower middleware wraps services. Axum provides a layer method to attach Tower layers to your router.
The tower-http Crate
The tower-http crate provides the middleware you'll actually use:
[dependencies]
tower-http = { version = "0.5", features = [
"cors",
"compression-gzip",
"request-id",
"trace",
"timeout",
] }
tower = { version = "0.4", features = ["util"] }
#![allow(unused)] fn main() { use axum::Router; use tower_http::{ compression::CompressionLayer, cors::{Any, CorsLayer}, request_id::{MakeRequestUuid, SetRequestIdLayer, PropagateRequestIdLayer}, timeout::TimeoutLayer, trace::TraceLayer, }; use std::time::Duration; fn build_router() -> Router { Router::new() .route("/", get(root_handler)) // Add middleware in reverse order of execution // (last added = outermost = first to run) .layer(TraceLayer::new_for_http()) .layer(CompressionLayer::new()) .layer(TimeoutLayer::new(Duration::from_secs(30))) .layer( CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any), ) .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid)) .layer(PropagateRequestIdLayer::x_request_id()) } }
Layer ordering matters. Layers are applied from the bottom of the stack up, wrapping the router. The outermost layer runs first. A common gotcha: if you put the timeout inside the trace layer, timeouts won't show up in your trace spans. Put the trace layer outermost.
Custom Middleware with axum::middleware::from_fn
For middleware that needs access to Axum-specific things (like extractors), use from_fn:
#![allow(unused)] fn main() { use axum::{ extract::Request, middleware::{self, Next}, response::Response, http::StatusCode, }; async fn require_api_key( req: Request, next: Next, ) -> Result<Response, StatusCode> { let key = req .headers() .get("X-Api-Key") .and_then(|v| v.to_str().ok()); if key != Some("secret-key") { return Err(StatusCode::UNAUTHORIZED); } Ok(next.run(req).await) } fn secured_router() -> Router { Router::new() .route("/secure", get(secure_handler)) .layer(middleware::from_fn(require_api_key)) } }
For middleware that needs state:
#![allow(unused)] fn main() { async fn auth_middleware( State(state): State<AppState>, req: Request, next: Next, ) -> Result<Response, StatusCode> { // state is available here Ok(next.run(req).await) } fn secured_router_with_state(state: AppState) -> Router { Router::new() .route("/secure", get(secure_handler)) .route_layer(middleware::from_fn_with_state(state.clone(), auth_middleware)) .with_state(state) } }
Note route_layer vs layer — route_layer only applies to routes, not to 404s. This matters for middleware that shouldn't run on every request (like authentication middleware that would otherwise log 404 attempts as auth failures).
Routing
Axum's router supports all the HTTP methods and path parameters you'd expect:
#![allow(unused)] fn main() { use axum::routing::{delete, get, patch, post, put}; let app = Router::new() // Static routes .route("/", get(index)) // Path parameters .route("/users/:id", get(get_user)) // Multiple methods on one route .route("/users", get(list_users).post(create_user)) // Nested routes .nest("/api/v1", api_v1_router()) // Catch-all (must be last) .route("/static/*path", get(serve_static)); }
Router Merging and Nesting
#![allow(unused)] fn main() { fn api_v1_router() -> Router<AppState> { Router::new() .route("/users", get(list_users)) .route("/users/:id", get(get_user).put(update_user).delete(delete_user)) } fn app(state: AppState) -> Router { Router::new() .nest("/api/v1", api_v1_router()) .nest("/api/v2", api_v2_router()) .with_state(state) } }
Notice Router<AppState> — when you split your router into functions, you need to thread the state type parameter. If this gets annoying, use Router<()> and pass state only at the top level with with_state. You'll need FromRef for sub-extractors.
Graceful Shutdown
Production servers need to stop cleanly when they receive SIGTERM:
use tokio::signal; #[tokio::main] async fn main() { let app = build_router(); 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 install Ctrl+C handler"); }; #[cfg(unix)] let terminate = async { signal::unix::signal(signal::unix::SignalKind::terminate()) .expect("Failed to install SIGTERM handler") .recv() .await; }; #[cfg(not(unix))] let terminate = std::future::pending::<()>(); tokio::select! { _ = ctrl_c => {}, _ = terminate => {}, } println!("Shutdown signal received"); }
When the shutdown future resolves, Axum stops accepting new connections and waits for in-flight requests to complete. If you have a timeout budget for graceful shutdown (e.g., 30 seconds in Kubernetes), you'll need to combine this with a timeout on the serve call itself.
Putting It Together
A complete, production-shaped router:
#![allow(unused)] fn main() { use axum::{ extract::{Path, State}, http::StatusCode, response::Json, routing::{get, post}, Router, }; use std::sync::Arc; use tower_http::{timeout::TimeoutLayer, trace::TraceLayer}; use std::time::Duration; #[derive(Clone)] struct AppState { // We'll fill this in the next chapter db: Arc<()>, } pub fn create_app(state: AppState) -> Router { let api = Router::new() .route("/users", get(list_users).post(create_user)) .route("/users/:id", get(get_user)); Router::new() .route("/health", get(health_check)) .nest("/api/v1", api) .layer(TraceLayer::new_for_http()) .layer(TimeoutLayer::new(Duration::from_secs(30))) .with_state(state) } async fn health_check() -> StatusCode { StatusCode::OK } async fn list_users(State(_state): State<AppState>) -> Json<Vec<serde_json::Value>> { Json(vec![]) } async fn create_user( State(_state): State<AppState>, Json(body): Json<serde_json::Value>, ) -> StatusCode { StatusCode::CREATED } async fn get_user( State(_state): State<AppState>, Path(id): Path<u32>, ) -> Result<Json<serde_json::Value>, StatusCode> { Err(StatusCode::NOT_FOUND) } }
In the next chapter we'll replace that Arc<()> with a real database pool.