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

flowchart LR root["/ (app)"] --> web["/ server-rendered pages
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();
}
$ 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
  • Inside the nested router, the Uri extractor sees the path with the prefix stripped. OriginalUri (default original-uri feature) returns the path as the client sent it — use it for redirects and links — and NestedPath returns 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}" extracts Path<(String, String)> = (version, id).

  • The prefix may not contain a wildcard (nested routes cannot contain wildcards (*)), and nesting at / panics with Nesting 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();
}

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

Router::fallback(handler)

handler for unmatched paths

Router::fallback_service(service)

the same with a tower Service — e.g. ServeDir to serve files for any path not handled by a route, or ServeFile for an SPA’s index.html

Router::method_not_allowed_fallback(handler)

handler for "path matched, method did not" (default: empty 405 with Allow), added in 0.8.0; the Allow header is still set

Router::reset_fallback()

(0.8.4) removes a custom fallback, so the router can be merged into one that has its own

MethodRouter::fallback(handler)

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();
}
$ 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

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/nope gets 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. Call reset_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::layer also wrap the fallback; layers added with route_layer do 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.

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();
}

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.