axum from Scratch: Building a JSON API
axum is an HTTP framework for Rust built on top of tokio, hyper, and
tower. It's deliberately minimal: no macros required, no framework-specific
traits to implement, just functions and the type system.
A minimal server
use axum::{routing::get, Router}; #[tokio::main] async fn main() { let app = Router::new().route("/", get(handler)); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); axum::serve(listener, app).await.unwrap(); } async fn handler() -> &'static str { "Hello, axum!" }
Handlers are plain async functions. axum's magic is that it converts a huge
variety of function signatures into valid handlers through the Handler trait
— extractors go in, responses come out.
Extractors
Extractors implement the FromRequest or FromRequestParts trait. axum ships
with a full suite:
use axum::{ extract::{Path, Query, State}, Json, }; use serde::Deserialize; #[derive(Deserialize)] struct Params { page: Option<u32>, } async fn list_posts( State(db): State<DbPool>, Path(tag): Path<String>, Query(params): Query<Params>, ) -> Json<Vec<Post>> { let page = params.page.unwrap_or(1); let posts = db.posts_by_tag(&tag, page).await; Json(posts) }
The order of extractors in the function signature doesn't matter (with one
exception: Body-consuming extractors like Json<T> must come last).
Shared state
State is injected via State<T> extractor after being registered on the router:
#[derive(Clone)] struct AppState { db: DbPool, } let state = AppState { db: pool }; let app = Router::new() .route("/posts", get(list_posts)) .with_state(state);
T must be Clone + Send + Sync + 'static. Wrapping expensive resources in
Arc is idiomatic.
Error handling
axum handlers return Result<T, E> where E: IntoResponse. Define a custom
error type:
use axum::{http::StatusCode, response::{IntoResponse, Response}}; enum AppError { NotFound, Internal(anyhow::Error), } impl IntoResponse for AppError { fn into_response(self) -> Response { match self { AppError::NotFound => (StatusCode::NOT_FOUND, "not found").into_response(), AppError::Internal(e) => { tracing::error!("{e:#}"); (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() } } } }
Now your handlers can use ? freely:
async fn get_post( State(db): State<AppState>, Path(slug): Path<String>, ) -> Result<Json<Post>, AppError> { let post = db.post_by_slug(&slug).await?; Ok(Json(post)) }
Middleware with tower
axum's middleware story is built on tower's Layer trait. Adding tracing,
compression, and rate limiting:
use tower_http::{ compression::CompressionLayer, trace::TraceLayer, }; let app = Router::new() .route("/posts", get(list_posts)) .layer(TraceLayer::new_for_http()) .layer(CompressionLayer::new());
Layers apply bottom-up (last added, first executed) — keep that in mind when ordering authentication and rate-limiting middleware.
What I like about axum
- Zero magic. If it compiles, you understand what it does.
- The extractor pattern scales well: test handlers by calling them directly with extracted types, no HTTP stack required.
- tower interoperability means you inherit a rich ecosystem of middleware.
The tradeoff is that the type errors can be gnarly when you're learning. But
once you've internalized extractors and IntoResponse, the model is
remarkably consistent.