Getting Started with Axum - Rust's Fastest-Growing Web Framework
Getting Started with Axum - Rust's Fastest-Growing Web Framework
Axum is the most downloaded Rust web framework on crates.io. It lives in the Tokio project and combines ergonomic API design with the full power of the Tower middleware ecosystem. If you're building a backend in Rust in 2026, Axum is a great place to start.
This guide walks you through everything you need to know - from your first route to a complete CRUD API. Every code example in this article compiles against Axum 0.8.9 (the latest release as of September 2026), together with tower-http 0.7, SQLx 0.9 and jsonwebtoken 11.
Table of Contents
- Why Axum?
- Quick Start: Hello World in 60 Seconds
- Routing Fundamentals
- Handler Functions and Responses
- Extractors: Path, Query, JSON, Form
- Application State with
State - Error Handling That Scales
- Middleware with Tower
- Database Integration with SQLx
- Authentication and Authorization
- Serving Static Files and SPAs
- WebSockets
- Putting It All Together
- Testing Without a Running Server
- Project Structure for Large Applications
- Deployment
- Axum vs Actix Web vs Rocket
- FAQ
Why Axum?
Before we write any code, here's why Axum has become the default choice for so many Rust backends:
- No routing macros required. Routes are plain Rust functions. No
#[get("/")]decorators, no magic - just types and functions. - Tower compatibility. Axum is built on
tower::Service, so Tower andtower-httpmiddleware plug straight in: tracing, compression, CORS, timeouts, body limits and more. - Part of the Tokio project. Axum is developed under the
tokio-rsGitHub organization, alongside the async runtime that powers most of the ecosystem. - Type-safe extractors. Handler arguments declare what they need from the request. The compiler checks that every argument is a valid extractor, and Axum parses the request into those types for you (returning a 4xx response when the input doesn't match).
- Readable handler errors. When a handler doesn't satisfy Axum's trait bounds, the
#[debug_handler]macro (behind themacrosfeature) turns a wall of trait errors into a message that points at the offending argument or return type.
Axum at a Glance
| Feature | Axum 0.8 |
|---|---|
| Async Runtime | Tokio |
| Middleware System | Tower / tower-http |
| Macro-free routing | Yes |
| Path parameter syntax | /{id} (new in 0.8) |
| Native async traits | Yes (no #[async_trait] for extractors) |
| WebSocket support | Built-in, behind the ws feature |
| HTTP/2 support | Behind the http2 feature |
| OpenAPI generation | Via the utoipa and utoipa-axum crates |
Quick Start: Hello World in 60 Seconds
Create a new project and add dependencies:
cargo new axum-api
cd axum-api
cargo add axum@0.8 tokio -F tokio/fullReplace src/main.rs:
use axum::{Router, routing::get};
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(|| async { "Hello, World!" }));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Listening on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}cargo run
# In another terminal:
curl http://localhost:3000
# => Hello, World!That's a working HTTP server in 10 lines of Rust. No macros beyond #[tokio::main], no boilerplate, no framework magic. Let's build on it.
Routing Fundamentals
Axum's Router maps HTTP methods and paths to handler functions:
use axum::{Router, routing::get};
async fn index() -> &'static str { "Home" }
async fn about() -> &'static str { "About" }
let app = Router::new()
.route("/", get(index))
.route("/about", get(about));Multiple Methods on One Route
Chain method handlers on a single path:
use axum::routing::get;
async fn list_users() -> &'static str { "List users" }
async fn create_user() -> &'static str { "Create user" }
let app = Router::new()
.route("/users", get(list_users).post(create_user));Path Parameters (New {param} Syntax in 0.8)
Axum 0.8 changed path parameter syntax from :param to {param}, which matches the syntax used by format!() and OpenAPI:
// Axum 0.8+ syntax
.route("/users/{id}", get(get_user))
.route("/files/{*path}", get(serve_file)) // wildcard
// Old syntax (pre-0.8) - panics when the route is added:
// .route("/users/:id", get(get_user))Migration note: If you're upgrading from Axum 0.7, replace
:paramwith{param}and*paramwith{*param}across all your route definitions. Axum 0.8 panics on segments that start with:or*so a missed route fails loudly at startup instead of silently never matching. If you genuinely need a literal:at the start of a segment, callRouter::without_v07_checks().
Nested Routers
Split routes by feature using nest:
let user_routes = Router::new()
.route("/", get(list_users).post(create_user))
.route("/{id}", get(get_user).put(update_user).delete(delete_user));
let app = Router::new()
.nest("/api/users", user_routes);
// Produces: GET /api/users, POST /api/users, GET /api/users/{id}, etc.Note: Since 0.8, nesting at the root (
.nest("/", ...)) panics. Usemergefor routers andfallback_servicefor services instead.
Merging Routers
When you want to combine routers that each define their own full paths:
let app = Router::new()
.merge(user_routes())
.merge(health_routes());Handler Functions and Responses
A handler is any async function that:
- Takes zero or more extractors as arguments
- Returns something that implements
IntoResponse
Returning Different Response Types
Axum implements IntoResponse for many types out of the box:
use axum::Json;
use axum::http::StatusCode;
use axum::response::Html;
// Plain text
async fn plain() -> &'static str {
"Hello"
}
// Status code only
async fn no_content() -> StatusCode {
StatusCode::NO_CONTENT
}
// JSON
async fn json() -> Json<serde_json::Value> {
Json(serde_json::json!({ "message": "Hello" }))
}
// HTML
async fn page() -> Html<&'static str> {
Html("<h1>Hello</h1>")
}
// Tuple: (StatusCode, Body)
async fn created() -> (StatusCode, Json<serde_json::Value>) {
(StatusCode::CREATED, Json(serde_json::json!({ "id": 1 })))
}The JSON examples need serde and serde_json:
cargo add serde -F derive
cargo add serde_jsonCustom Response Types with IntoResponse
For larger APIs, define an enum for your responses:
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
#[derive(Serialize)]
struct User {
id: u64,
name: String,
}
enum ApiResponse {
Ok,
Created,
JsonData(Vec<User>),
}
impl IntoResponse for ApiResponse {
fn into_response(self) -> Response {
match self {
Self::Ok => StatusCode::OK.into_response(),
Self::Created => StatusCode::CREATED.into_response(),
Self::JsonData(data) => (StatusCode::OK, Json(data)).into_response(),
}
}
}
async fn list_users() -> ApiResponse {
ApiResponse::JsonData(vec![User {
id: 1,
name: "Alice".into(),
}])
}Extractors: Path, Query, JSON, Form
Extractors pull data out of incoming HTTP requests. They're passed as function parameters and Axum resolves them automatically. If extraction fails (a non-numeric id, malformed JSON, a missing field), Axum rejects the request with a 4xx response before your handler runs.
Path Parameters
use axum::extract::Path;
async fn get_user(Path(id): Path<u64>) -> String {
format!("User #{id}")
}
// Multiple path params
async fn get_comment(
Path((post_id, comment_id)): Path<(u64, u64)>,
) -> String {
format!("Post {post_id}, Comment {comment_id}")
}
// Route: .route("/posts/{post_id}/comments/{comment_id}", get(get_comment))Query Parameters
use axum::extract::Query;
use serde::Deserialize;
#[derive(Deserialize)]
struct Pagination {
page: Option<u32>,
per_page: Option<u32>,
}
async fn list_items(Query(pagination): Query<Pagination>) -> String {
let page = pagination.page.unwrap_or(1);
let per_page = pagination.per_page.unwrap_or(20);
format!("Page {page}, {per_page} items")
}
// GET /items?page=2&per_page=50JSON Body
use axum::Json;
use serde::Deserialize;
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
async fn create_user(Json(input): Json<CreateUser>) -> String {
format!("Created user: {} ({})", input.name, input.email)
}The Json extractor requires a Content-Type: application/json header and responds with 415 Unsupported Media Type without it.
Form Data
use axum::Form;
use serde::Deserialize;
#[derive(Deserialize)]
struct LoginForm {
username: String,
password: String,
}
async fn login(Form(input): Form<LoginForm>) -> String {
format!("Login attempt: {}", input.username)
}Multiple Extractors in One Handler
You can combine extractors freely. The only rule: at most one extractor can consume the request body (Json, Form, Bytes, etc.), and it must be the last parameter.
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
};
use std::sync::Arc;
async fn update_item(
State(state): State<Arc<AppState>>, // from app state
Path(id): Path<u64>, // from URL
Query(params): Query<Pagination>, // from query string
Json(body): Json<UpdateItem>, // from body (must be last)
) -> StatusCode {
// ...
StatusCode::OK
}Custom Extractors (No #[async_trait] in 0.8)
Axum 0.8 removed the need for #[async_trait] when implementing extractors. FromRequest and FromRequestParts now use Rust's native async fn in traits:
use axum::{
extract::FromRequestParts,
http::{StatusCode, request::Parts},
};
struct CurrentUser {
user_id: u64,
}
impl<S> FromRequestParts<S> for CurrentUser
where
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
// Extract user from headers, cookies, JWT, etc.
let user_id = parts
.headers
.get("x-user-id")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
Ok(CurrentUser { user_id })
}
}
// Use it like any other extractor
async fn profile(user: CurrentUser) -> String {
format!("User #{}", user.user_id)
}Application State with State
Real applications need shared state: database pools, configuration, caches. Axum's State extractor provides type-safe access to shared data. The state type must implement Clone, which is why it is usually wrapped in an Arc.
Basic State with Arc
use axum::{Json, Router, extract::State, routing::get};
use sqlx::PgPool;
use std::sync::Arc;
struct AppState {
db_pool: PgPool,
config: AppConfig,
}
#[tokio::main]
async fn main() {
let state = Arc::new(AppState {
db_pool: create_pool().await,
config: load_config(),
});
let app = Router::new()
.route("/users", get(list_users))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn list_users(State(state): State<Arc<AppState>>) -> Json<Vec<User>> {
let users = sqlx::query_as::<_, User>("SELECT id, name FROM users")
.fetch_all(&state.db_pool)
.await
.unwrap();
Json(users)
}Substates for Access Control
Limit what specific routes can access. FromRef lets a handler extract just one field of the app state:
use axum::extract::{FromRef, State};
#[derive(Clone, FromRef)]
struct AppState {
api_state: ApiState,
admin_state: AdminState,
}
#[derive(Clone)]
struct ApiState {
db_pool: PgPool,
}
#[derive(Clone)]
struct AdminState {
admin_key: String,
}
// This handler can only access ApiState, not AdminState
async fn public_endpoint(State(api): State<ApiState>) -> &'static str {
"Public"
}#[derive(FromRef)] needs the macros feature (cargo add axum -F macros). Without it, write one impl FromRef<AppState> for ApiState per field that returns app.api_state.clone().
Error Handling That Scales
Axum's error handling model is straightforward: handlers return Result<T, E> where both T and E implement IntoResponse.
Define a Central Error Type
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
enum AppError {
NotFound(String),
BadRequest(String),
Unauthorized,
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".into()),
AppError::Internal(msg) => {
tracing::error!("Internal error: {msg}");
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong".into())
}
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}Convert From Library Errors
Implement From to use the ? operator seamlessly:
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
match err {
sqlx::Error::RowNotFound => AppError::NotFound("Resource not found".into()),
_ => AppError::Internal(err.to_string()),
}
}
}
// Now handlers are clean:
async fn get_user(
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
) -> Result<Json<User>, AppError> {
let user = sqlx::query_as::<_, User>("SELECT id, name FROM users WHERE id = $1")
.bind(id)
.fetch_one(&state.db_pool)
.await?; // Automatically converts sqlx::Error -> AppError
Ok(Json(user))
}Middleware with Tower
Axum's superpower is its deep integration with Tower. Any Tower middleware whose error type is Infallible can be added with .layer(), and that covers almost everything in tower-http. Middleware that can fail (such as tower::timeout) must be wrapped in HandleErrorLayer so the error becomes a response.
Built-in Tower-HTTP Layers
cargo add tower-http -F trace,compression-full,cors,timeout,limituse axum::http::StatusCode;
use std::time::Duration;
use tower_http::{
compression::CompressionLayer, cors::CorsLayer, limit::RequestBodyLimitLayer,
timeout::TimeoutLayer, trace::TraceLayer,
};
let app = Router::new()
.route("/", get(index))
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive())
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(30),
))
.layer(RequestBodyLimitLayer::new(1024 * 1024)); // 1MB limitNote:
TimeoutLayer::newis deprecated since tower-http 0.6.7. UseTimeoutLayer::with_status_codeso the status returned on timeout is explicit.
Layer order matters. Each
.layer()call wraps everything added before it. The last.layer()call is the outermost layer (runs first on request, last on response). If you want top-to-bottom ordering, group layers withtower::ServiceBuilder.
Writing Custom Middleware
axum::middleware::from_fn turns an async function into middleware. The request type is axum::extract::Request (an alias for http::Request<axum::body::Body>):
use axum::{extract::Request, middleware::Next, response::Response};
async fn timing_middleware(req: Request, next: Next) -> Response {
let start = std::time::Instant::now();
let method = req.method().clone();
let uri = req.uri().clone();
let response = next.run(req).await;
let duration = start.elapsed();
tracing::info!("{method} {uri} -> {} in {duration:?}", response.status());
response
}
// Apply it:
let app = Router::new()
.route("/", get(index))
.layer(axum::middleware::from_fn(timing_middleware));Middleware with State
use axum::{
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::Response,
};
async fn auth_middleware(
State(state): State<Arc<AppState>>,
req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let token = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
// Validate token against your state
if !validate_token(&state, token).await {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(next.run(req).await)
}
let app = Router::new()
.route("/protected", get(protected_handler))
.layer(axum::middleware::from_fn_with_state(state.clone(), auth_middleware))
.with_state(state);Applying Middleware to Specific Routes
Use route_layer to apply middleware only to routes defined above it:
let app = Router::new()
.route("/admin", get(admin_panel))
.route_layer(axum::middleware::from_fn(require_admin)) // only /admin
.route("/public", get(public_page)); // no middlewareUnlike layer, route_layer only runs when a route matches, so requests to unknown paths still get a plain 404 instead of passing through your middleware.
Database Integration with SQLx
Here's how to wire up PostgreSQL with SQLx and Axum for a complete CRUD API.
Dependencies
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.9", features = ["runtime-tokio", "postgres", "uuid", "macros", "migrate"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
uuid = { version = "1", features = ["v4", "serde"] }SQLx 0.9 requires Rust 1.94 or newer. The uuid feature lets SQLx encode and decode Postgres UUID columns as uuid::Uuid.
Setting Up the Connection Pool
use sqlx::{PgPool, postgres::PgPoolOptions};
struct AppState {
db: PgPool,
}
#[tokio::main]
async fn main() {
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://user:pass@localhost/mydb".into());
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to connect to database");
// Run migrations from ./migrations
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Failed to run migrations");
let state = Arc::new(AppState { db: pool });
let app = Router::new()
.route("/todos", get(list_todos).post(create_todo))
.route(
"/todos/{id}",
get(get_todo).put(update_todo).delete(delete_todo),
)
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}CRUD Handlers
These handlers use SQLx's runtime query API with #[derive(FromRow)], so they compile without a database. They reuse the AppError type from the error handling section.
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Serialize, FromRow)]
struct Todo {
id: Uuid,
title: String,
completed: bool,
}
#[derive(Debug, Deserialize)]
struct CreateTodo {
title: String,
}
#[derive(Debug, Deserialize)]
struct UpdateTodo {
title: Option<String>,
completed: Option<bool>,
}
async fn list_todos(State(state): State<Arc<AppState>>) -> Result<Json<Vec<Todo>>, AppError> {
let todos = sqlx::query_as::<_, Todo>("SELECT id, title, completed FROM todos")
.fetch_all(&state.db)
.await?;
Ok(Json(todos))
}
async fn create_todo(
State(state): State<Arc<AppState>>,
Json(input): Json<CreateTodo>,
) -> Result<(StatusCode, Json<Todo>), AppError> {
let todo = sqlx::query_as::<_, Todo>(
"INSERT INTO todos (id, title, completed) VALUES ($1, $2, false)
RETURNING id, title, completed",
)
.bind(Uuid::new_v4())
.bind(input.title)
.fetch_one(&state.db)
.await?;
Ok((StatusCode::CREATED, Json(todo)))
}
async fn get_todo(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<Todo>, AppError> {
let todo = sqlx::query_as::<_, Todo>("SELECT id, title, completed FROM todos WHERE id = $1")
.bind(id)
.fetch_one(&state.db)
.await?;
Ok(Json(todo))
}
async fn update_todo(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(input): Json<UpdateTodo>,
) -> Result<Json<Todo>, AppError> {
let todo = sqlx::query_as::<_, Todo>(
"UPDATE todos
SET title = COALESCE($2, title),
completed = COALESCE($3, completed)
WHERE id = $1
RETURNING id, title, completed",
)
.bind(id)
.bind(input.title)
.bind(input.completed)
.fetch_one(&state.db)
.await?;
Ok(Json(todo))
}
async fn delete_todo(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
let result = sqlx::query("DELETE FROM todos WHERE id = $1")
.bind(id)
.execute(&state.db)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Todo not found".into()));
}
Ok(StatusCode::NO_CONTENT)
}Compile-time checked queries: SQLx also offers
query!andquery_as!macros that check your SQL against a real database while compiling. They needDATABASE_URLset at build time (or an offline cache generated withcargo sqlx prepare), andquery_as!maps columns by name without needingFromRow. Swap them in once your schema is stable.
Authentication and Authorization
A common pattern in Axum is to use a custom extractor for authentication instead of middleware. This keeps your handlers clean and makes auth requirements visible in each handler's signature.
JWT Auth Extractor
jsonwebtoken 10 and later make you pick a crypto backend. rust_crypto is pure Rust; aws_lc_rs is the alternative. Without either, signing and verifying panic at runtime:
cargo add jsonwebtoken -F rust_cryptouse axum::{
extract::FromRequestParts,
http::{StatusCode, header::AUTHORIZATION, request::Parts},
};
use jsonwebtoken::{DecodingKey, Validation, decode};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
}
struct AuthUser {
user_id: String,
}
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let token = parts
.headers
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(StatusCode::UNAUTHORIZED)?;
// In production, load the secret from an environment variable
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(b"your-secret-key"),
&Validation::default(),
)
.map_err(|_| StatusCode::UNAUTHORIZED)?;
Ok(AuthUser {
user_id: token_data.claims.sub,
})
}
}
// Usage - auth is enforced by the extractor's presence:
async fn my_profile(user: AuthUser) -> String {
format!("Hello, user {}", user.user_id)
}
// No auth needed - just don't include the extractor:
async fn public_page() -> &'static str {
"Anyone can see this"
}Validation::default() checks the signature with HS256 and rejects expired tokens using the exp claim.
Optional Authentication with Option<T>
In Axum 0.7, Option<T> swallowed every rejection and gave you None. Axum 0.8 changed this. Option<T> now only works for extractors that implement OptionalFromRequestParts (or OptionalFromRequest), and that implementation decides which cases mean "absent" and which are still errors.
For AuthUser, a missing header should mean an anonymous visitor, but a bad token should still be rejected:
use axum::extract::OptionalFromRequestParts;
impl<S> OptionalFromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> Result<Option<Self>, Self::Rejection> {
// No Authorization header: an anonymous visitor
if !parts.headers.contains_key(AUTHORIZATION) {
return Ok(None);
}
// A header is present, so it has to be valid
<AuthUser as FromRequestParts<S>>::from_request_parts(parts, state)
.await
.map(Some)
}
}
async fn feed(user: Option<AuthUser>) -> String {
match user {
Some(u) => format!("Personalized feed for {}", u.user_id),
None => "Public feed".into(),
}
}Now a request with no Authorization header gets the public feed, and a request with an invalid token gets 401 Unauthorized. If you want the old "any failure becomes None" behaviour for an extractor, use Result<T, T::Rejection> as the argument and match on it.
Serving Static Files and SPAs
Use tower-http to serve static files alongside your API:
cargo add tower-http -F fsStatic File Server
Serve everything that isn't an API route from a directory. Because nesting at / panics in 0.8, the directory goes in fallback_service:
use tower_http::services::ServeDir;
let app = Router::new()
.nest("/api", api_routes())
.fallback_service(ServeDir::new("static"));Single-Page Application (React, Vue, etc.)
For SPAs, unknown paths should return index.html so the client-side router can handle them:
use tower_http::services::{ServeDir, ServeFile};
let app = Router::new()
.nest("/api", api_routes())
.fallback_service(
ServeDir::new("dist").fallback(ServeFile::new("dist/index.html")),
);Use .fallback(...) here rather than .not_found_service(...). Both serve index.html, but not_found_service always sets the status to 404 Not Found, so every deep link into your app would look like an error to crawlers and uptime checks. fallback keeps the 200 OK from ServeFile.
WebSockets
Axum has first-class WebSocket support behind the ws feature:
cargo add axum -F wsuse axum::{
extract::ws::{WebSocket, WebSocketUpgrade},
response::Response,
};
async fn ws_handler(ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(handle_socket)
}
async fn handle_socket(mut socket: WebSocket) {
while let Some(Ok(msg)) = socket.recv().await {
// Echo messages back
if socket.send(msg).await.is_err() {
break;
}
}
}
// Route:
// .route("/ws", get(ws_handler))Note: In 0.8, text and binary messages carry
Utf8BytesandBytesinstead ofStringandVec<u8>. Build them withMessage::Text("hi".into()), and call.as_str()on received text.
Putting It All Together
All the pieces above - routing, extractors, state, error handling, middleware - compose naturally. Here is a complete todo API you can run as-is. To keep it runnable without a database it stores todos in memory. Swap the RwLock<Vec<Todo>> for the SQLx handlers above when you're ready.
Dependencies:
cargo add axum@0.8 tokio -F tokio/full
cargo add serde -F derive
cargo add serde_json tracing tracing-subscriber
cargo add uuid -F v4,serde
cargo add tower-http -F cors,tracesrc/main.rs:
use std::sync::Arc;
use axum::{
Json, Router,
extract::{Path, Request, State},
http::StatusCode,
middleware::{self, Next},
response::{IntoResponse, Response},
routing::get,
};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use uuid::Uuid;
#[derive(Clone, Serialize)]
struct Todo {
id: Uuid,
title: String,
completed: bool,
}
#[derive(Deserialize)]
struct CreateTodo {
title: String,
}
#[derive(Deserialize)]
struct UpdateTodo {
title: Option<String>,
completed: Option<bool>,
}
struct AppState {
todos: RwLock<Vec<Todo>>,
}
enum AppError {
NotFound,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
match self {
AppError::NotFound => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Todo not found" })),
)
.into_response(),
}
}
}
async fn health() -> &'static str {
"OK"
}
async fn list_todos(State(state): State<Arc<AppState>>) -> Json<Vec<Todo>> {
Json(state.todos.read().await.clone())
}
async fn create_todo(
State(state): State<Arc<AppState>>,
Json(input): Json<CreateTodo>,
) -> (StatusCode, Json<Todo>) {
let todo = Todo {
id: Uuid::new_v4(),
title: input.title,
completed: false,
};
state.todos.write().await.push(todo.clone());
(StatusCode::CREATED, Json(todo))
}
async fn get_todo(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<Todo>, AppError> {
let todos = state.todos.read().await;
let todo = todos.iter().find(|t| t.id == id).ok_or(AppError::NotFound)?;
Ok(Json(todo.clone()))
}
async fn update_todo(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(input): Json<UpdateTodo>,
) -> Result<Json<Todo>, AppError> {
let mut todos = state.todos.write().await;
let todo = todos
.iter_mut()
.find(|t| t.id == id)
.ok_or(AppError::NotFound)?;
if let Some(title) = input.title {
todo.title = title;
}
if let Some(completed) = input.completed {
todo.completed = completed;
}
Ok(Json(todo.clone()))
}
async fn delete_todo(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
let mut todos = state.todos.write().await;
let len = todos.len();
todos.retain(|t| t.id != id);
if todos.len() == len {
return Err(AppError::NotFound);
}
Ok(StatusCode::NO_CONTENT)
}
async fn log_request(req: Request, next: Next) -> Response {
let method = req.method().clone();
let uri = req.uri().clone();
let response = next.run(req).await;
tracing::info!("{method} {uri} -> {}", response.status());
response
}
fn app(state: Arc<AppState>) -> Router {
Router::new()
.route("/health", get(health))
.route("/todos", get(list_todos).post(create_todo))
.route(
"/todos/{id}",
get(get_todo).put(update_todo).delete(delete_todo),
)
.layer(middleware::from_fn(log_request))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
.with_state(state)
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let state = Arc::new(AppState {
todos: RwLock::new(Vec::new()),
});
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
tracing::info!("Listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app(state)).await.unwrap();
}Each handler uses the patterns we covered - State for shared data, Path and Json extractors, and Result<T, AppError> for error handling. Tower layers handle cross-cutting concerns.
Test it with curl:
# Health check
curl http://localhost:3000/health
# Create a todo
curl -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title":"Learn Axum"}'
# List todos
curl http://localhost:3000/todos
# Update a todo (replace <uuid> with an id from the list)
curl -X PUT http://localhost:3000/todos/<uuid> \
-H "Content-Type: application/json" \
-d '{"completed":true}'
# Delete a todo
curl -X DELETE http://localhost:3000/todos/<uuid>Testing Without a Running Server
Axum routers are Tower services, so you can test them directly with oneshot - no HTTP server needed. These tests go at the bottom of the src/main.rs from the previous section:
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_health_check() {
let state = Arc::new(AppState {
todos: RwLock::new(Vec::new()),
});
let app = app(state);
let response = app
.oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&body[..], b"OK");
}
#[tokio::test]
async fn test_create_and_list_todos() {
let state = Arc::new(AppState {
todos: RwLock::new(Vec::new()),
});
// Create a todo
let response = app(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/todos")
.header("content-type", "application/json")
.body(Body::from(r#"{"title":"Test todo"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
// List todos
let response = app(state)
.oneshot(Request::builder().uri("/todos").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let todos: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert_eq!(todos.len(), 1);
assert_eq!(todos[0]["title"], "Test todo");
}
}Add to [dev-dependencies]:
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"Project Structure for Large Applications
As your API grows, organize by feature:
src/
โโโ main.rs # Server setup
โโโ config.rs # Configuration
โโโ error.rs # AppError enum
โโโ state.rs # AppState
โโโ routes/
โ โโโ mod.rs # Route modules
โ โโโ users.rs # User handlers
โ โโโ todos.rs # Todo handlers
โ โโโ auth.rs # Auth handlers
โโโ extractors/
โ โโโ mod.rs
โ โโโ auth.rs # AuthUser
โโโ middleware/
โ โโโ mod.rs
โ โโโ timing.rs # Request timing
โ โโโ logging.rs # Custom logging
โโโ models/
โโโ mod.rs
โโโ user.rs # User + DB queries
โโโ todo.rs # Todo + DB queries
migrations/
โโโ 001_create_users.sql
โโโ 002_create_todos.sqlEach route module exports a function that returns a Router:
// src/routes/users.rs
use axum::{Router, routing::get};
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/users", get(list).post(create))
.route("/users/{id}", get(show).put(update).delete(destroy))
}
// src/routes/mod.rs
pub fn all_routes() -> Router<Arc<AppState>> {
Router::new()
.merge(users::router())
.merge(todos::router())
.merge(auth::router())
}
// src/main.rs
let app = routes::all_routes()
.layer(TraceLayer::new_for_http())
.with_state(state);The Router<Arc<AppState>> type says "this router still needs an Arc<AppState>". Calling .with_state(state) once at the top supplies it and turns the whole tree into a plain Router.
Deployment
Graceful Shutdown
Production servers need graceful shutdown:
use tokio::signal;
#[tokio::main]
async fn main() {
// ... build your app ...
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 listen for Ctrl+C");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("Failed to listen for SIGTERM")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("Shutting down gracefully...");
}Once the signal fires, axum::serve stops accepting new connections and waits for in-flight requests to finish before returning.
Axum vs Actix Web vs Rocket
Here's an honest comparison as of 2026:
| Axum 0.8 | Actix Web 4 | Rocket 0.5 | |
|---|---|---|---|
| Performance | Excellent | Excellent, often slightly ahead in synthetic benchmarks | Good |
| Middleware | Tower ecosystem (huge) | Own system (mature) | Fairings (built-in) |
| Routing | Plain functions, no macros | Attribute macros or builder API | Attribute macros |
| Async runtime | Tokio | Tokio, via actix-rt | Tokio |
| Learning curve | Moderate | Moderate | Low (batteries included) |
| Ecosystem | Largest and growing fast | Mature | Smaller |
| Release cadence | Regular 0.8.x releases | Regular 4.x releases | No release since 0.5.1 (May 2024) |
Choose Axum for: Most applications, Tower ecosystem compatibility, type-safe design, and Tokio integration.
Choose Actix Web for: Teams already invested in it, or workloads where its benchmark lead matters to you (measure your own workload first - the gap is small for typical apps).
Choose Rocket for: Rapid prototyping and small projects where its batteries-included developer experience matters more than release activity.
For new projects in 2026, Axum is the default choice in the Rust ecosystem. Its adoption, its place in the Tokio project and its middleware compatibility make it the safest long-term bet.
FAQ
What version of Axum should I use?
Use Axum 0.8.x (0.8.9 at the time of writing) - the current release line with the {param} path syntax, native async traits for extractors, and OptionalFromRequestParts.
Is Axum 1.0 out?
No. Axum is still on 0.x, so minor version bumps (0.7 to 0.8) can contain breaking changes. Patch releases within 0.8 are backwards compatible.
Do I need #[async_trait] for custom extractors?
No. Axum 0.8 uses Rust's native async fn in traits. Remove #[async_trait] from all FromRequest and FromRequestParts implementations when you upgrade.
How do I handle CORS?
Use tower-http with the cors feature:
use tower_http::cors::CorsLayer;
let app = Router::new()
.route("/api/data", get(handler))
.layer(CorsLayer::permissive()); // or configure specific originsCan I use Axum with GraphQL?
Yes. The async-graphql crate has Axum 0.8 integration via async-graphql-axum.
How do I add request validation?
Use the validator crate inside a custom extractor that deserializes and then validates the input. For nicer error messages when built-in extractors reject a request, axum-extra provides WithRejection.
Is Axum production-ready?
Yes. Despite the 0.x version number, Axum is widely used in production and is maintained under the Tokio project. Pin a minor version (axum = "0.8") and read the changelog before moving to the next one.
How do I generate OpenAPI docs?
Use the utoipa crate. Its derive macros generate OpenAPI specs from your types and handlers, and utoipa-axum registers routes and their docs in one place.
What's Next?
You now have everything you need to build Rust APIs with Axum. Here are some next steps:
- Add a database - Replace the in-memory store with the SQLx handlers shown above.
- Add authentication - Build the JWT extractor from the auth section.
- Generate OpenAPI docs - Use
utoipato auto-generate Swagger documentation. - Practice - Try the Rust challenges on Rustfinity to sharpen your skills.
Axum's combination of type safety, Tower compatibility, and macro-free API design makes it an excellent choice for Rust web development in 2026. Start building.
Subscribe to our newsletter
Get the latest updates on courses, features, tools, and resources about Rust.
Learn Rust by Practice
Master Rust through hands-on coding exercises and real-world examples.
Get Started