How to build a REST API with Axum
You have a service idea. You need endpoints that accept JSON, return structured data, and handle errors without crashing. You've built this before in Python or JavaScript. You know the pattern: define a route, write a function that processes the request, return a response. Now you're in Rust. You see mentions of axum, tower, hyper, and tokio. You want to spin up a server that feels familiar but gives you the safety and performance Rust promises. You need a clear path from main() to a running endpoint that accepts requests and sends responses.
Axum is the framework that makes this straightforward. It sits on top of the Tokio runtime and uses a type-driven approach to routing. You write async functions. Axum inspects their signatures and figures out how to extract data from the request and how to format the response. You focus on the business logic. Axum handles the HTTP plumbing.
The concierge model
Think of Axum as a highly organized concierge at a hotel. Guests arrive at the front desk with requests. The concierge checks the guest's request, looks at the URL and the HTTP method, and hands the guest off to the right specialist. The specialist does the work and hands back a result. The concierge ensures the specialist has the tools they need, like a database connection or configuration settings.
In Rust terms, the concierge is the Router. The specialists are your handler functions. The tools are the State. The router matches incoming requests to handlers. The handlers process the request. The state provides shared context that handlers can access. This separation keeps your code modular. Each handler knows only what it needs. The router knows how to connect everything together.
The router matches, the handler runs, the state provides context. Keep these roles separate.
Minimal working example
Start with the smallest possible server. This example defines a single route that returns a static string. It shows the structure of an Axum application without any complexity.
use axum::{routing::get, Router};
// The runtime drives the async server.
// #[tokio::main] sets up the event loop automatically.
#[tokio::main]
async fn main() {
// Build the router and attach a route.
// get(handler) tells Axum to call handler for GET requests.
let app = Router::new().route("/", get(handler));
// Bind to an address.
// 0.0.0.0 listens on all network interfaces.
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
// Start serving.
// This blocks until the server stops.
axum::serve(listener, app).await.unwrap();
}
// Handlers can be simple async functions.
// Axum extracts arguments and returns responses based on types.
// This handler takes no arguments and returns a static string.
async fn handler() -> &'static str {
"Hello, World!"
}
Add these dependencies to your Cargo.toml:
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
The community convention is to use #[tokio::main] for the entry point. It hides the runtime initialization boilerplate. You'll see this macro on almost every Axum project. It tells the compiler to generate a main function that starts the Tokio runtime and runs the async block inside.
Run this and hit localhost. You should see the text. If you see an error about the address being in use, change the port.
What happens under the hood
When you run this code, tokio starts an event loop. The TcpListener waits for TCP connections. When a browser hits http://localhost:3000, the connection lands. Axum parses the HTTP request. It checks the path against the routes. It finds / matches get(handler). It calls handler. The function returns a &'static str. Axum sees the return type and automatically converts it into an HTTP response with status 200 and the text body. The response goes back to the client.
The conversion from return type to HTTP response is automatic. Axum supports many types out of the box. String, &str, StatusCode, Json<T>, and tuples all work. If you return a type Axum doesn't recognize, the compiler rejects the code. This is a feature. It catches response errors at compile time instead of at runtime.
Realistic example with state and JSON
Real APIs need to share data between handlers and process JSON. This example adds a shared state struct, path extraction, JSON parsing, and error handling. It shows how to structure a service that persists data in memory.
use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
// Shared state needs to be Send + Sync for multi-threading.
// Arc makes it thread-safe to share across handlers.
// Mutex ensures only one handler modifies the map at a time.
#[derive(Clone)]
struct AppState {
users: Arc<std::sync::Mutex<HashMap<String, String>>>,
}
// Request body structure.
// Deserialize allows Axum to parse JSON into this struct.
#[derive(Deserialize)]
struct CreateUser {
name: String,
}
// Response body structure.
// Serialize allows Axum to convert this struct to JSON.
#[derive(Serialize)]
struct UserResponse {
id: String,
name: String,
}
// Handler extracts state and path parameter.
// State<AppState> gets a clone of the shared state.
// Path<String> extracts the :id parameter from the URL.
async fn get_user(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<UserResponse>, StatusCode> {
// Lock the mutex to read data.
// Unwrap is acceptable here because lock failure is rare.
let users = state.users.lock().unwrap();
// Return 404 if user not found.
// Map converts the Option to a Result.
users.get(&id).map(|name| {
Json(UserResponse { id, name: name.clone() })
}).ok_or(StatusCode::NOT_FOUND)
}
// Handler extracts state and JSON body.
// Json<CreateUser> parses the request body.
async fn create_user(
State(state): State<AppState>,
Json(payload): Json<CreateUser>,
) -> (StatusCode, Json<UserResponse>) {
// Generate a simple ID.
let id = format!("user-{}", rand::random::<u32>());
// Lock the mutex to write data.
let mut users = state.users.lock().unwrap();
users.insert(id.clone(), payload.name);
// Return 201 Created with the new user.
(StatusCode::CREATED, Json(UserResponse { id, name: "Created".to_string() }))
}
#[tokio::main]
async fn main() {
// Initialize shared state.
// Arc wraps the Mutex which wraps the HashMap.
let state = AppState {
users: Arc::new(std::sync::Mutex::new(HashMap::new())),
};
// Build the router.
// with_state attaches the state to all routes.
let app = Router::new()
.route("/users/:id", get(get_user))
.route("/users", post(create_user))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
The community relies heavily on serde for JSON. Always derive Serialize and Deserialize on your data structures. It saves hours of manual parsing code. Axum integrates with serde automatically. When you use Json<T>, Axum uses serde_json to parse or serialize the data.
State management is the bridge between isolated handlers and shared resources. Design your state struct early.
Pitfalls and compiler errors
Axum's type system catches mistakes early. You'll encounter a few common patterns that trip up new users. Understanding these patterns saves debugging time.
If your state struct doesn't implement Clone, the router rejects it. You get E0277 (trait bound not satisfied) because Axum needs to clone the state for each route. The router stores a clone of the state and passes it to handlers. Wrap your data in Arc and derive Clone on the state struct. The Clone implementation on the state struct should just clone the Arc pointers, not the underlying data. This keeps cloning cheap.
Handlers are called for every request. This means they cannot consume their arguments. If you write a handler that tries to take ownership of the state, the compiler stops you. You'll see E0507 (cannot move out of borrowed content) or similar borrow errors. The solution is always to borrow the state or clone the inner data. Axum's State extractor gives you a clone of the state, so you can use it freely. You can clone the Arc inside the state or lock the mutex to access the data.
Blocking the runtime is a silent killer. If you call a blocking function inside an async handler, you freeze a worker thread. The server stops responding to other requests. Use tokio::task::spawn_blocking for CPU-heavy or blocking I/O work. Database drivers that support async should be used directly. If you must use a blocking driver, wrap the call in spawn_blocking.
If you return a type Axum doesn't know how to handle, the compiler complains about trait bounds. Axum supports String, &str, Json<T>, StatusCode, and tuples. If you return a custom struct without Serialize, you'll see errors about missing trait implementations. Add #[derive(Serialize)] to the struct or wrap it in Json.
The compiler errors here are your friends. They catch concurrency bugs before they hit production. Read the error message. It usually tells you exactly what trait is missing.
When to use Axum
Use Axum when you want a modern, ergonomic framework that integrates deeply with the Tokio ecosystem. Use Axum when you need type-safe routing and extraction without writing boilerplate. Use Axum when you are building a service that requires high concurrency and low latency. Use Axum when you prefer a handler-based architecture where each endpoint is a standalone async function.
Reach for Actix-web when you need a battle-tested alternative with a different architecture based on actors. Reach for Warp when you prefer a combinator-based routing style that feels more functional. Reach for Poem when you want a framework that emphasizes simplicity and follows the Hyper API closely.
Pick the tool that matches your team's mental model. Axum is the current standard for a reason.