You’ve heard the rumors: Rust is blazing fast and memory‑safe, but writing backend services feels intimidating. What if it could feel like building with LEGO — intuitive, composable, and fun? Enter Axum, a modern Web framework built by the Tokio team.

In this tutorial you’ll:

  • Understand Axum’s core concepts: Router, handlers, extractors
  • Work with path parameters, query parameters, and JSON
  • Add shared state with Arc<Mutex<T>>
  • Install middleware such as structured tracing

Step 1: Add dependencies

axum = "0.7"
tokio = { version = "1", features = ["full"] }

Or via CLI:

cargo add axum
cargo add tokio --features full

Step 2: Your first Axum app

use axum::{routing::get, Router};
use std::net::SocketAddr;

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

    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("🚀 Listening on {addr}");

    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn root_handler() -> &'static str {
    "Hello from Axum!"
}

Step 3: Path parameters

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

let app = Router::new()
    .route("/", get(root_handler))
    .route("/hello/:name", get(greet));

async fn greet(Path(name): Path<String>) -> String {
    format!("👋 Hello, {name}! Nice to meet you.")
}

Step 4: Query parameters

use axum::extract::{Query, Path};
use serde::Deserialize;

#[derive(Deserialize)]
struct SearchParams {
    q: String,
    lang: Option<String>,
}

let app = Router::new().route("/search", get(search));

async fn search(Query(params): Query<SearchParams>) -> String {
    let language = params.lang.unwrap_or_else(|| "unknown".to_string());
    format!(
        "🔍 Search received! q='{0}', lang='{1}'",
        params.q, language
    )
}

Step 5: JSON input/output

use axum::{extract::Json, response::IntoResponse};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct CreateUser { username: String, email: String }

#[derive(Serialize)]
struct User { id: u64, username: String, email: String }

let app = Router::new().route("/users", axum::routing::post(create_user));

async fn create_user(Json(payload): Json<CreateUser>) -> impl IntoResponse {
    let user = User { id: 1337, username: payload.username, email: payload.email };
    (axum::http::StatusCode::CREATED, Json(user))
}

Step 6: Shared state with Arc<Mutex<T>>

use axum::{extract::Extension, routing::get, Router};
use std::sync::{Arc, Mutex};

type SharedState = Arc<Mutex<u32>>;

#[tokio::main]
async fn main() {
    let shared_state: SharedState = Arc::new(Mutex::new(0));

    let app = Router::new()
        .route("/", get(root_handler))
        .route("/hits", get(hit_counter))
        .layer(Extension(shared_state));
}

async fn hit_counter(Extension(state): Extension<SharedState>) -> String {
    let mut count = state.lock().unwrap();
    *count += 1;
    format!("You are visitor #{count}!")
}

Step 7: Middleware — request tracing

cargo add tower-http --features trace
use tower_http::trace::TraceLayer;

let app = Router::new()
    // ... routes and layers above
    .layer(TraceLayer::new_for_http());

Graduation challenge

Build a mini server with:

  • /count (GET): global counter with shared state
  • /echo?text=... (GET): echoes the text query parameter
  • /api/greet (POST): accepts { "name": "..." } and returns { "message": "Welcome, ..." }
  • Global tracing middleware

Happy shipping! 🚀