Web Framework with Axum

Reviewed & published by Brayan K

Axum is a modern, ergonomic web framework built on Tokio and tower — it turns ordinary async functions into HTTP handlers with type-safe request extraction and zero boilerplate.

Part of the free Rust course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll build a Router, write handlers, pull data from requests with extractors, return typed responses, and share state across your whole application.

What You'll Learn in This Lesson

1️⃣ A Router and Your First Handler

An Axum app is a Router that maps a path + method to a handler. A handler is just an async fn; if its return type implements IntoResponse (a String does), it becomes the HTTP response.

// Axum is an async web framework built on Tokio and tower.
// A Router maps paths + methods to handler functions.
use axum::{routing::get, Router};

// A handler is just an async fn. Returning a String is a valid response
// because String implements IntoResponse.
async fn root() -> &'static str {
    "Hello, Axum!"
}

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

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

    // axum::serve drives the router on the listener until shutdown.
    axum::serve(listener, app).await.unwrap();
}

axum::serve binds a Tokio TcpListener and drives the router. No threads to manage — Tokio schedules every connection as a task.

2️⃣ Extractors: Path, Query, and Json

Extractors pull typed data out of a request. Path reads dynamic segments like :id, Query parses the query string, and Json deserializes the body. A body-consuming extractor like Json must be the last argument.

// Extractors pull typed data OUT of the request: Path, Query, Json, and more.
use axum::{
    extract::{Path, Query, Json},
    routing::{get, post},
    Router,
};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct Pagination { page: u32 }

#[derive(Deserialize, Serialize)]
struct User { id: u32, name: String }

// Path<u32> reads the :id segment; the return value becomes a JSON response.
async fn get_user(Path(id): Path<u32>) -> Json<User> {
    Json(User { id, name: "Ada".into() })
}

// Query<Pagination> deserializes ?page=2 from the query string.
async fn list_users(Query(p): Query<Pagination>) -> String {
    format!("listing page {}", p.page)
}

// Json<User> deserializes the request body into a User.
async fn create_user(Json(user): Json<User>) -> Json<User> {
    Json(user)
}

fn app() -> Router {
    Router::new()
        .route("/users/:id", get(get_user))
        .route("/users", get(list_users))
        .route("/users", post(create_user))
}

Returning Json<User> serializes the struct and sets the JSON content type automatically. Extraction failures (bad JSON, wrong types) become error responses without any extra code.

3️⃣ Shared State and Middleware

Real apps need shared resources — a database pool, config, a counter. Attach them with .with_state(...) and read them through the State extractor. Cross-cutting concerns like logging or timeouts are added as tower .layer(...) middleware.

// Shared state (config, a DB pool, a counter) is injected with State.
use axum::{extract::State, routing::get, Router, http::StatusCode, Json};
use std::sync::{Arc, Mutex};

// State is usually cheap to clone; Arc shares one value across handlers.
#[derive(Clone)]
struct AppState { hits: Arc<Mutex<u64>> }

async fn count(State(state): State<AppState>) -> (StatusCode, Json<u64>) {
    let mut hits = state.hits.lock().unwrap();
    *hits += 1;
    // A (StatusCode, Json<..>) tuple implements IntoResponse.
    (StatusCode::OK, Json(*hits))
}

#[tokio::main]
async fn main() {
    let state = AppState { hits: Arc::new(Mutex::new(0)) };

    let app = Router::new()
        .route("/count", get(count))
        .with_state(state); // makes State<AppState> available to handlers

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

The state type must be Clone; an Arc shares one underlying value across every request handled by every worker thread.

Mini-Challenge: A Greeting Route

Build a route /hello/:name that greets the caller using the Path extractor.

// 🎯 MINI-CHALLENGE: build a tiny greeting API.
//
// 1. Write an async handler  greet(Path(name): Path<String>) -> String
//    that returns  format!("Hello, {name}!").
// 2. Build a Router with  .route("/hello/:name", get(greet)).
// 3. Serve it on 127.0.0.1:3000 with axum::serve.
//
// Expected:  GET /hello/Sam  ->  Hello, Sam!

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

#[tokio::main]
async fn main() {
    // your code here
}

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Axum

GoalRust Syntax
New routerRouter::new()
GET route.route("/", get(handler))
Path paramPath(id): Path<u32>
JSON bodyJson(body): Json<T>
Shared state.with_state(state)
Middleware.layer(TraceLayer::new())

🎉 Lesson Complete!

Practice quiz

What does Axum's Router do?

  • Maps URL paths and methods to handler functions
  • Serializes JSON
  • Manages async runtimes
  • Connects to a database

Answer: Maps URL paths and methods to handler functions. A Router maps each route (path plus HTTP method) to the handler that serves it.

Which extractor reads a dynamic path segment like /users/:id?

  • Query
  • State
  • Path
  • Json

Answer: Path. The Path extractor pulls typed values out of dynamic path segments.

Which extractor parses the URL query string (?page=2)?

  • Header
  • Query
  • Path
  • Form

Answer: Query. The Query extractor deserializes the query string into a struct.

How does a handler receive a parsed JSON request body?

  • By calling parse()
  • Axum cannot parse JSON
  • By reading State
  • By taking a Json<T> extractor argument

Answer: By taking a Json<T> extractor argument. A Json<T> argument deserializes the request body into T using serde.

What must a handler's return type implement to be a response?

  • IntoResponse
  • FromRow
  • Future
  • Serialize

Answer: IntoResponse. Any handler return type that implements IntoResponse can be turned into an HTTP response.

How do you share application state (like a DB pool) with handlers?

  • Environment variables
  • The State extractor with .with_state(...)
  • Global static mut
  • The Json extractor

Answer: The State extractor with .with_state(...). Attach state with .with_state(...) and read it via the State extractor.

Which method registers a GET handler in Axum?

  • route_get(handler)
  • handle(handler)
  • GET(handler)
  • get(handler)

Answer: get(handler). You write .route(path, get(handler)) using the get() method router.

Returning a tuple like (StatusCode, Json(value)) works because...

  • It is special-cased syntax
  • Tuples are futures
  • Axum implements IntoResponse for such tuples
  • It is converted to a String

Answer: Axum implements IntoResponse for such tuples. Axum implements IntoResponse for tuples of a status code and a body.

Axum is built on top of which lower-level service abstraction?

  • serde
  • tower (Service / Layer)
  • rayon
  • hyper-tls

Answer: tower (Service / Layer). Axum builds on tower, so middleware is added as tower Layers.

How is middleware typically added to an Axum Router?

  • With .layer(...)
  • With .middleware(...)
  • With .use(...)
  • It cannot have middleware

Answer: With .layer(...). Middleware is applied with .layer(...), wrapping the router in a tower Layer.

Continue this course

Frequently asked questions

What is an extractor in Axum?

An extractor is a handler argument whose type tells Axum how to pull a piece of data out of the incoming request. Path<T> reads dynamic path segments like :id, Query<T> deserializes the URL query string, Json<T> parses the request body as JSON, and State<T> hands you shared application state. Axum runs the extractors in order before calling your handler, and if extraction fails (for example, malformed JSON) it returns an error response automatically. You can list several extractors as parameters, but body-consuming extractors such as Json must come last because they take ownership of the request body.

How do handlers produce responses?

A handler can return anything that implements the IntoResponse trait, and Axum converts it into a real HTTP response. A &'static str or String becomes a 200 text response, Json<T> serializes T to a JSON body with the right content type, and a tuple like (StatusCode, Json<T>) sets both the status and the body. Result<T, E> works too when both the Ok and Err types implement IntoResponse, which is the usual way to return either a success payload or an error status. This trait-based design means you rarely build a Response by hand.

How do I share a database pool or config with every handler?

Put it in a state struct and attach it with Router::with_state(state). Handlers then receive it through the State extractor, for example State(state): State<AppState>. The state type must be Clone, and because Axum may call handlers from many tasks concurrently, shared mutable data is typically wrapped in an Arc (and a Mutex or RwLock if it needs mutation). A connection pool such as sqlx's PgPool is already cheap to clone and internally synchronized, so you usually store the pool directly in your state struct and clone it per request.

How does middleware work in Axum?

Axum is built on the tower ecosystem, so middleware is expressed as a tower Layer and applied with .layer(...) on a Router or an individual route. Layers wrap your handlers to add cross-cutting behavior like logging, timeouts, compression, or authentication. tower-http ships many ready-made layers (TraceLayer, CorsLayer, TimeoutLayer), and you can write custom middleware with axum::middleware::from_fn for a simple async function form. Because layers are composable, you stack them to build the full request and response pipeline.