Axum Routing Explained With Handlers and Responses
Axum routes map a path and method to an async function. Learn path parameters, nesting, fallbacks and the 0.8 syntax change for captures.

Routing in Axum is small enough to learn in an afternoon and has three details that account for most of the questions on the issue tracker: the path syntax changed in 0.8, a path with the wrong method answers differently from a path that does not exist, and the type a handler returns decides the response without any builder. This post builds a runnable service with every routing feature you will use in practice, shows the requests and the responses they produce, and ends with the trap that makes a nested router answer with a surprising 404. Everything was compiled against axum 0.8.9 and tokio 1 on Rust 1.99.
A router in thirty lines
[package]
name = "routing-demo"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }use axum::{
Router,
extract::Path,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
async fn index() -> &'static str {
"hello"
}
async fn user(Path(id): Path<u32>) -> String {
format!("user {id}")
}
async fn create_user() -> impl IntoResponse {
(StatusCode::CREATED, "created")
}
async fn file(Path(path): Path<String>) -> String {
format!("file at {path}")
}
async fn not_found() -> (StatusCode, &'static str) {
(StatusCode::NOT_FOUND, "no such route")
}
fn api() -> Router {
Router::new()
.route("/users", post(create_user))
.route("/users/{id}", get(user))
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(index))
.route("/files/{*path}", get(file))
.nest("/api", api())
.fallback(not_found);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Run it with cargo run and call it with curl. Each line below is a request followed by what came back:
curl -i localhost:3000/
curl -i localhost:3000/api/users/42
curl -i -X POST localhost:3000/api/users
curl -i localhost:3000/files/docs/a/b.txt
curl -i localhost:3000/nopeHTTP/1.1 200 OK
hello
HTTP/1.1 200 OK
user 42
HTTP/1.1 201 Created
created
HTTP/1.1 200 OK
file at docs/a/b.txt
HTTP/1.1 404 Not Found
no such routePath syntax: braces in 0.8
Axum 0.8 moved to the matchit 0.8 router, and with it the path syntax changed from the /:id form that older tutorials use to /{id}. A catch-all, which swallows the rest of the path including slashes, is /{*path}. Using the old form is not a compile error. The router panics when it is built, at startup:
thread 'main' panicked at src/main.rs:32:10:
Path segments must not start with `:`. For capture groups, use `{capture}`. If you meant to literally match a segment starting with a colon, call `without_v07_checks` on the router.That helpful panic fires on the first run, so a copied example cannot reach production broken. If you need a literal brace in a path, double it: /{{literal}}. A capture matches exactly one segment, so /users/{id} does not match /users/42/posts; add a route for that or nest. The wildcard is greedy and must be last. Routes are matched by specificity, not by order of registration, so /users/new beats /users/{id} regardless of which line comes first, which removes a common source of bugs in other frameworks. Two routes that could match the same request with equal specificity are a panic when you build the router, again at startup.
Methods on one path
A MethodRouter bundles handlers for one path, built by chaining the helpers from axum::routing:
use axum::routing::{delete, get, put};
fn items() -> Router {
Router::new().route(
"/items/{id}",
get(read_item).put(replace_item).delete(remove_item),
)
}
async fn read_item() -> &'static str { "read" }
async fn replace_item() -> &'static str { "replaced" }
async fn remove_item() -> StatusCode { StatusCode::NO_CONTENT }Chain the methods rather than repeating .route("/items/{id}", ...) three times, because a second route call for a path that already exists merges only if the methods differ and panics if they overlap. When a request arrives with a method that has no handler, Axum answers 405 Method Not Allowed with an Allow header listing the methods that exist, and does so for you. That is a different outcome from the fallback, which only sees paths that match nothing. A DELETE to /users/42 in the first example returns 405, not your custom 404 body, and a test for "unknown route returns JSON" that tries a wrong method will fail for this reason.
Nesting and merging
Two combinators compose routers, and they do different things:
| Method | What it does | Use it for |
|---|---|---|
nest("/api", router) | mounts router under a prefix; the prefix is removed before matching inside | grouping by area, versioned APIs, applying middleware to a group |
merge(router) | combines the routes at the same level | splitting route definitions across files with no prefix |
nest_service("/static", svc) | mounts any Tower service under a prefix | static files, another application |
Inside a nested router the prefix is gone, so a handler registered at /users inside api() serves /api/users. If the handler needs the full original URI, extract OriginalUri. The behavior that surprises people is the fallback: a router nested under a prefix inherits the outer fallback unless it has its own, and a fallback set on the outer router after the nest call still applies to unmatched paths inside the prefix, so /api/nothing returns your not_found body. Set a fallback on the inner router when the API should answer in its own format, and keep one on the outer router for the site. The next section's middleware point depends on it: layers applied to a nested router affect its routes and not its siblings, covered in middleware with Tower layers.
What a handler is
A handler is an async function, with no registration or trait you implement. Axum accepts it when two things hold: every argument implements an extractor trait, and the return type implements IntoResponse. That is why the examples above return &'static str, String, StatusCode and a tuple, with no response object. Tuples are the composition tool: (StatusCode, HeaderMap, Json<T>) sets status, headers and body in one expression. The argument side, including why extractor order matters, is the subject of extractors and the order that matters, and the return side, including Result and your own error type, of error handling with IntoResponse.
When a handler fails to compile, the error is famously unhelpful: "the trait bound fn(...) -> ... {handler}: Handler<_, _> is not satisfied". The fix is a one-line attribute from the macros feature that turns it into a precise message pointing at the argument at fault:
use axum::debug_handler;
#[debug_handler]
async fn user(Path(id): Path<u32>) -> String {
format!("user {id}")
}Add features = ["macros"] to the axum dependency and put #[debug_handler] on any handler that will not register. Remove it afterwards if you like; it only changes diagnostics. The usual causes it uncovers are a non-Send value held across an .await, an extractor that consumes the body placed before another that reads it, and a return type that does not implement IntoResponse.
A request that matches two routes
The specificity rule is easy to state and worth seeing once. Register a literal path and a capture that could both match, in the "wrong" order, and look at who wins:
let app = Router::new()
.route("/users/{id}", get(|Path(id): Path<String>| async move { format!("user {id}") }))
.route("/users/new", get(|| async { "the new-user form" }));curl localhost:3000/users/new
curl localhost:3000/users/42the new-user form
user 42The literal segment beats the capture whatever order they were registered in, because the underlying radix tree prefers the more specific edge. That removes a whole class of "my catch-all route swallows everything" bugs from frameworks that match top to bottom. The routes that do conflict, two captures at the same position with different names, such as /users/{id} and /users/{name}, panic when the router is built, with a message naming both, so the mistake cannot reach a deploy.
Where this routing model stops
Axum's router is static: all routes are known when you build it, which is what makes conflicts a startup panic and dispatch fast. If routes must be added at run time (a plugin system, user-defined endpoints), either rebuild the router and swap it behind an ArcSwap, or route on one wildcard and dispatch in your own code. Large applications gain from splitting routers by module, each module exporting pub fn router() -> Router<AppState>, and the structure used by a real service is in an Axum production service. Path handling that touches the filesystem needs more care than a wildcard extractor offers; see safe path joins before turning /files/{*path} into a file read.


