Routing Composition
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06. Real applications are built
from many small routers, one per resource or feature, combined into a single tree. axum offers two combinators — nest (mount under a prefix) and merge (combine at the same level) — and a set of fallbacks that decide what
happens when nothing matches. Single routes and the path syntax are covered on Routing.
Where it runs in the request
In the request lifecycle (tokio accepts
the connection, hyper parses the request, tower layers wrap the router, the router matches a route, middleware and
extractors run, the handler runs, the response travels back out), nest, merge and fallbacks shape the router
step: they are resolved when the router is built, so at request time there is still one route table and one match,
after hyper and any outer tower layers, before middleware, extractors and the handler. Layers attached to a nested
router run only for requests that matched inside it, and the nested router’s fallback runs in place of a handler when
nothing inside matched.
The Bookshelf router tree
merge(web::router())"] root --> api["/api/v1
nest(api::router())"] root --> ws["/ws
live review feed"] root --> events["/events
SSE loan notifications"] root --> static["/static
nest_service(ServeDir)"] root --> docs["/docs
Swagger UI"] root --> fb["fallback: HTML 404 page"] api --> books["/books, /books/{isbn}"] api --> authors["/authors, /authors/{id}"] api --> reviews["/books/{isbn}/reviews"] api --> loans["/loans, /loans/{id}/return"] api --> apifb["fallback: Problem Details 404"] web --> cat["/, /books/{isbn}"] web --> acct["/login, /logout, /account"]
Each box is built in its own module and returned as a Router<AppState>; app.rs only assembles them. The JSON API has
its own fallback so that unknown API paths answer with a JSON error, while everything else gets an HTML page.
nest: mounting a router under a prefix
Router::nest(prefix, router) makes every route of router available under prefix:
use axum::{
Router,
extract::{NestedPath, OriginalUri},
http::Uri,
routing::get,
};
async fn list_books(uri: Uri, OriginalUri(original): OriginalUri, nested: NestedPath) -> String {
format!("uri={uri} original={original} nested_at={}\n", nested.as_str())
}
fn books() -> Router {
Router::new()
.route("/", get(|| async { "books root\n" }))
.route("/books", get(list_books))
}
fn app() -> Router {
Router::new().nest("/api/v1", books())
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app()).await.unwrap();
}
Source: axum: Router::nest
$ curl localhost:3000/api/v1/books
uri=/books original=/api/v1/books nested_at=/api/v1
$ curl localhost:3000/api/v1
books root
$ curl -i localhost:3000/api/v1/ | head -1
HTTP/1.1 404 Not Found
Source: axum: Router::nest
-
Inside the nested router, the
Uriextractor sees the path with the prefix stripped.OriginalUri(defaultoriginal-urifeature) returns the path as the client sent it — use it for redirects and links — andNestedPathreturns the prefix the router was mounted at. -
A nested route
"/"matches the prefix itself (/api/v1), not the prefix with a trailing slash (/api/v1/). -
The prefix may contain captures, which the nested handlers can extract: with
nest("/api/{version}/users", users), a handler on"/{id}"extractsPath<(String, String)>= (version,id). -
The prefix may not contain a wildcard (
nested routes cannot contain wildcards (*)), and nesting at/panics withNesting at the root is no longer supported. Use merge instead. -
Middleware applied to the nested router with
.layer()before nesting only runs for its routes.
nest_service(prefix, service) mounts any tower Service — typically ServeDir — under a prefix. The service receives
every request whose path starts with the prefix, with the prefix stripped, whatever the method (see
Static Files & SPAs).
merge: combining routers side by side
Router::merge(other) adds all of other's routes to self at the same level — no prefix. It is how modules that own
complete paths (/login, /account) are combined:
use axum::{Router, routing::get};
mod web {
use axum::{Router, response::Html, routing::get};
pub fn router() -> Router {
Router::new()
.route("/", get(|| async { Html("<h1>Bookshelf</h1>") }))
.route("/login", get(|| async { Html("<form>...</form>") }))
}
}
mod health {
use axum::{Router, routing::get};
pub fn router() -> Router {
Router::new().route("/health", get(|| async { "ok" })).route("/ready", get(|| async { "ready" }))
}
}
fn app() -> Router {
Router::new()
.merge(web::router())
.merge(health::router())
.route("/version", get(|| async { env!("CARGO_PKG_VERSION") }))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app()).await.unwrap();
}
Source: axum: Router::merge
Merging two routers that register the same method on the same path panics (Overlapping method route), exactly as two
route calls would. Merging two routers that both have a custom fallback panics too — see below.
nest or merge? Use nest when a sub-router’s paths are relative to a mount point it should not know about (an API
version, a tenant prefix); use merge when the sub-router already owns absolute paths.
Fallbacks
The fallback is the handler that runs when no route matches. Without one, axum answers 404 Not Found with an empty
body.
| Method | Effect |
|---|---|
|
handler for unmatched paths |
|
the same with a tower |
|
handler for "path matched, method did not" (default: empty |
|
(0.8.4) removes a custom fallback, so the router can be merged into one that has its own |
|
per-route handler for methods that route does not register |
use axum::{
Json, Router,
http::{StatusCode, Uri},
response::{Html, IntoResponse},
routing::get,
};
use serde_json::json;
async fn api_not_found(uri: Uri) -> impl IntoResponse {
(
StatusCode::NOT_FOUND,
[("content-type", "application/problem+json")],
Json(json!({ "type": "about:blank", "title": "Not Found", "status": 404, "instance": uri.path() })),
)
}
async fn page_not_found() -> impl IntoResponse {
(StatusCode::NOT_FOUND, Html("<h1>Page not found</h1><p><a href=\"/\">Back to the catalogue</a></p>"))
}
async fn method_not_allowed() -> impl IntoResponse {
(StatusCode::METHOD_NOT_ALLOWED, "this endpoint does not support that method\n")
}
fn api() -> Router {
Router::new()
.route("/books", get(|| async { "books\n" }))
.fallback(api_not_found) // used for /api/v1/<anything unmatched>
}
fn app() -> Router {
Router::new()
.route("/", get(|| async { Html("<h1>Bookshelf</h1>") }))
.nest("/api/v1", api())
.fallback(page_not_found) // used everywhere else
.method_not_allowed_fallback(method_not_allowed)
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app()).await.unwrap();
}
Source: axum example: global-404-handler
$ curl -s localhost:3000/api/v1/nope
{"instance":"/nope","status":404,"title":"Not Found","type":"about:blank"}
$ curl -s localhost:3000/nope
<h1>Page not found</h1><p><a href="/">Back to the catalogue</a></p>
$ curl -si -X DELETE localhost:3000/ | head -1
HTTP/1.1 405 Method Not Allowed
Source: axum example: global-404-handler
Note that the nested fallback sees the stripped path (/nope); use OriginalUri if the error body must show the full
path. A fallback handler returns 200 OK unless it sets a status itself — the most common mistake with custom 404
handlers.
How fallbacks are inherited (axum 0.8)
-
A nested router without its own fallback inherits the outer router’s fallback, so
/api/v1/nopegets the outer handler. -
A nested router with its own fallback uses it for every unmatched path under its prefix (including the bare prefix when it has no
"/"route). -
Merging two routers that both have a custom fallback panics with
Cannot merge two Routers that both have a fallback. Callreset_fallback()on one of them before merging if both were built with fallbacks (for example, sub-routers that are also served standalone in tests). -
Layers added with
Router::layeralso wrap the fallback; layers added withroute_layerdo not (see Applying Middleware & Ordering).
The unreleased axum 0.9 changes how fallbacks of nested routers are merged with the outer router’s; check the changelog when upgrading if your application relies on the inheritance rules above.
Source: axum: Router::fallback
API versioning
Path versioning (/api/v1, /api/v2) is the most visible and cache-friendly scheme. When both versions share most
handlers, nest the same router under a captured version and extract it with a small custom extractor, as the official
versioning example does:
use std::collections::HashMap;
use axum::{
RequestPartsExt, Router,
extract::{FromRequestParts, Path},
http::{StatusCode, request::Parts},
response::{IntoResponse, Response},
routing::get,
};
#[derive(Debug, Clone, Copy)]
enum Version {
V1,
V2,
}
impl<S: Send + Sync> FromRequestParts<S> for Version {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let params: Path<HashMap<String, String>> = parts.extract().await.map_err(IntoResponse::into_response)?;
match params.get("version").map(String::as_str) {
Some("v1") => Ok(Version::V1),
Some("v2") => Ok(Version::V2),
Some(_) => Err((StatusCode::NOT_FOUND, "unknown API version").into_response()),
None => Err((StatusCode::INTERNAL_SERVER_ERROR, "missing version parameter").into_response()),
}
}
}
async fn list_books(version: Version) -> String {
match version {
Version::V1 => "[\"Dune\"]\n".to_owned(), // v1: titles only
Version::V2 => "[{\"title\":\"Dune\",\"year\":1965}]\n".to_owned(), // v2: objects
}
}
fn app() -> Router {
let api = Router::new().route("/books", get(list_books));
Router::new().nest("/api/{version}", api)
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app()).await.unwrap();
}
Source: axum example: versioning
When versions diverge substantially, give each its own router module and nest them at fixed prefixes
(nest("/api/v1", v1::router()).nest("/api/v2", v2::router())). Header- or media-type-based versioning is possible
with a custom extractor reading Accept, but it is harder to cache and to try in a browser. The
JSON REST APIs page discusses deprecating old versions.
Where the books are outdated
Yoon (Beginning Axum, chapter 2) nests routers in the 0.8 style. Older tutorials nest at "/" to combine routers;
in axum 0.8 that panics and must be merge. method_not_allowed_fallback was added in 0.8.0 and reset_fallback in
0.8.4, and before 0.6 axum redirected trailing slashes automatically.