How does axum::routing::Router::fallback handle unmatched routes for custom 404 responses?

axum::routing::Router::fallback registers a handler that processes any request not matching defined routes, enabling custom 404 responses with application-specific content types, status codes, and error formats instead of the default plain text "Not Found" response. When a request arrives at an axum application, the router attempts to match the request path and method against registered routes; if no route matches, the fallback handler is invoked with the original request, allowing applications to return structured JSON errors, HTML pages, redirects, or any other response type. The fallback mechanism is essential for APIs that need consistent error response formats—returning a JSON error object with request ID, error code, and message rather than plain text—and for web applications that need styled 404 pages matching the site's branding.

Default 404 Behavior

use axum::{
    routing::get,
    Router,
    http::StatusCode,
};
use std::net::SocketAddr;
 
async fn hello() -> &'static str {
    "Hello, World!"
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/hello", get(hello));
    
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
// GET /hello -> "Hello, World!"
// GET /unknown -> 404 Not Found (plain text, default axum response)
// GET /missing -> 404 Not Found (default response)

Without a fallback, unmatched routes return 404 Not Found with a plain text body.

Basic Fallback Handler

use axum::{
    routing::get,
    Router,
    http::StatusCode,
    response::IntoResponse,
};
use std::net::SocketAddr;
 
async fn hello() -> &'static str {
    "Hello, World!"
}
 
async fn handle_404() -> impl IntoResponse {
    (StatusCode::NOT_FOUND, "Custom 404 - Page not found")
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/hello", get(hello))
        .fallback(handle_404);
    
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
// GET /hello -> "Hello, World!"
// GET /unknown -> 404 "Custom 404 - Page not found"
// Any unmatched route goes to handle_404

fallback() registers a handler for all unmatched routes.

JSON Error Responses for APIs

use axum::{
    routing::get,
    Router,
    http::{StatusCode, Uri},
    response::IntoResponse,
    Json,
};
use serde::Serialize;
 
#[derive(Serialize)]
struct ApiError {
    error: String,
    code: u16,
    path: String,
}
 
async fn api_not_found(uri: Uri) -> impl IntoResponse {
    let error = ApiError {
        error: "Not Found".to_string(),
        code: 404,
        path: uri.to_string(),
    };
    
    (StatusCode::NOT_FOUND, Json(error))
}
 
async fn get_users() -> &'static str {
    "users"
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users", get(get_users))
        .fallback(api_not_found);
    
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
// GET /users -> "users"
// GET /unknown -> 404 {"error":"Not Found","code":404,"path":"/unknown"}

APIs return structured JSON errors for consistent client handling.

Accessing Request Information in Fallback

use axum::{
    routing::get,
    Router,
    http::{Method, Uri, StatusCode},
    response::IntoResponse,
    extract::Request,
};
use serde::Serialize;
 
#[derive(Serialize)]
struct DetailedError {
    message: String,
    status: u16,
    method: String,
    path: String,
}
 
async fn detailed_fallback(method: Method, uri: Uri) -> impl IntoResponse {
    let error = DetailedError {
        message: format!("No route matches {} {}", method, uri),
        status: 404,
        method: method.to_string(),
        path: uri.to_string(),
    };
    
    (StatusCode::NOT_FOUND, axum::Json(error))
}
 
async fn home() -> &'static str {
    "Home page"
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(home))
        .fallback(detailed_fallback);
    
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
// GET / -> "Home page"
// POST /missing -> 404 with {"message":"No route matches POST /missing",...}

The fallback can extract method, headers, URI, and body like regular handlers.

Fallback with Request Body Access

use axum::{
    routing::post,
    Router,
    http::{StatusCode, Uri},
    response::IntoResponse,
    extract::Request,
    body::Body,
};
use serde::{Deserialize, Serialize};
 
#[derive(Deserialize)]
struct ApiRequest {
    name: String,
}
 
#[derive(Serialize)]
struct ErrorResponse {
    error: String,
    hint: String,
}
 
async fn fallback_with_body(request: Request) -> impl IntoResponse {
    let uri = request.uri().clone();
    let method = request.method().clone();
    
    // Could inspect body for certain endpoints
    let error = ErrorResponse {
        error: format!("{} {} not found", method, uri),
        hint: "Check API documentation at /docs".to_string(),
    };
    
    (StatusCode::NOT_FOUND, axum::Json(error))
}
 
async fn create_user(body: String) -> &'static str {
    "User created"
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users", post(create_user))
        .fallback(fallback_with_body);
    
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

The fallback receives the full request, including body if needed.

HTML 404 Pages for Web Applications

use axum::{
    routing::get,
    Router,
    http::{StatusCode, Uri},
    response::IntoResponse,
    response::Html,
};
 
async fn home() -> Html<&'static str> {
    Html("<h1>Welcome</h1><p><a href='/about'>About</a></p>")
}
 
async fn about() -> Html<&'static str> {
    Html("<h1>About Us</h1><p>Information page</p>")
}
 
async fn html_404(uri: Uri) -> impl IntoResponse {
    let html = format!(
        r#"<!DOCTYPE html>
<html>
<head>
    <title>404 Not Found</title>
    <style>
        body {{ font-family: sans-serif; text-align: center; padding: 50px; }}
        h1 {{ color: #e74c3c; }}
        a {{ color: #3498db; text-decoration: none; }}
    </style>
</head>
<body>
    <h1>Page Not Found</h1>
    <p>The path <code>{}</code> does not exist.</p>
    <p><a href="/">Return Home</a></p>
</body>
</html>"#,
        uri
    );
    
    (StatusCode::NOT_FOUND, Html(html))
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(home))
        .route("/about", get(about))
        .fallback(html_404);
    
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Web applications return styled HTML 404 pages matching site branding.

Redirect as Fallback

use axum::{
    routing::get,
    Router,
    http::{StatusCode, Uri},
    response::IntoResponse,
    response::Redirect,
};
 
async fn old_page() -> &'static str {
    "This is the old page"
}
 
async fn redirect_fallback(uri: Uri) -> impl IntoResponse {
    // Redirect unknown paths to home
    // Useful for legacy URL handling
    Redirect::permanent("/")
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/old", get(old_page))
        .fallback(redirect_fallback);
    
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
// GET /old -> "This is the old page"
// GET /anything-else -> 301 redirect to /

Fallbacks can redirect unknown paths, useful for legacy URL migration.

Method-Specific Fallback Behavior

use axum::{
    routing::{get, post, put, delete},
    Router,
    http::{Method, StatusCode, Uri},
    response::IntoResponse,
    Json,
};
use serde::Serialize;
 
#[derive(Serialize)]
struct MethodError {
    error: String,
    allowed_methods: Vec<&'static str>,
}
 
async fn method_fallback(method: Method, uri: Uri) -> impl IntoResponse {
    let error = MethodError {
        error: format!("{} {} not found", method, uri),
        allowed_methods: vec
!["GET", "POST", "PUT", "DELETE"],
    };
    
    // Could return 404 for unknown paths
    // Or 405 for known paths with wrong method
    (StatusCode::NOT_FOUND, Json(error))
}
 
async fn users_get() -> &'static str {
    "List users"
}
 
async fn users_post() -> &'static str {
    "Create user"
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users", get(users_get).post(users_post))
        .fallback(method_fallback);
    
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
// GET /users -> "List users"
// POST /users -> "Create user"
// DELETE /users -> 404 (no DELETE route)
// GET /unknown -> 404

The fallback sees the method, enabling method-specific responses.

Nested Routers and Fallbacks

use axum::{
    routing::get,
    Router,
    http::{StatusCode, Uri},
    response::IntoResponse,
    Json,
};
use serde::Serialize;
 
#[derive(Serialize)]
struct ApiError {
    error: String,
    path: String,
}
 
async fn api_users() -> &'static str {
    "Users API"
}
 
async fn api_fallback(uri: Uri) -> impl IntoResponse {
    let error = ApiError {
        error: "API endpoint not found".to_string(),
        path: uri.to_string(),
    };
    (StatusCode::NOT_FOUND, Json(error))
}
 
async fn web_home() -> &'static str {
    "Web Home"
}
 
async fn web_fallback() -> impl IntoResponse {
    (StatusCode::NOT_FOUND, "Page not found - check URL")
}
 
#[tokio::main]
async fn main() {
    let api_routes = Router::new()
        .route("/users", get(api_users))
        .fallback(api_fallback);
    
    let web_routes = Router::new()
        .route("/", get(web_home))
        .fallback(web_fallback);
    
    // Each nested router can have its own fallback
    let app = Router::new()
        .nest("/api", api_routes)
        .nest("/web", web_routes);
    
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
// GET /api/users -> "Users API"
// GET /api/unknown -> JSON error (api_fallback)
// GET /web/ -> "Web Home"
// GET /web/unknown -> "Page not found" (web_fallback)
// GET /unknown -> 404 default (no top-level fallback)

Nested routers can have their own fallbacks for different URL prefixes.

Fallback for SPA Routing

use axum::{
    routing::{get, get_service},
    Router,
    http::{StatusCode, Uri},
    response::IntoResponse,
};
use tower_http::services::ServeDir;
 
async fn index() -> &'static str {
    // Serve the SPA index.html
    "<!DOCTYPE html><html><body>SPA App</body></html>"
}
 
async fn spa_fallback() -> impl IntoResponse {
    // For SPAs, return index.html for any unmatched route
    // The client-side router handles the path
    (
        StatusCode::OK,
        [("Content-Type", "text/html")],
        "<!DOCTYPE html><html><body>SPA App</body></html>"
    )
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        // Static assets
        .nest_service("/static", ServeDir::new("./static"))
        // API routes
        .route("/api/health", get(|| async { "OK" }))
        // Fallback serves SPA index for client-side routing
        .fallback(spa_fallback);
    
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
// GET /api/health -> "OK"
// GET /static/app.js -> serves from ./static
// GET /dashboard -> SPA index (client router handles /dashboard)
// GET /users/123 -> SPA index (client router handles /users/123)

SPAs use fallbacks to serve the index.html for all client-side routes.

Fallback Service vs Handler

use axum::{
    routing::get,
    Router,
    http::{StatusCode, Uri},
    response::IntoResponse,
};
use tower::Service;
use std::net::SocketAddr;
 
async fn hello() -> &'static str {
    "Hello"
}
 
// Handler function (simpler)
async fn handler_fallback(uri: Uri) -> impl IntoResponse {
    (StatusCode::NOT_FOUND, format!("Not found: {}", uri))
}
 
// Service (more control, can implement tower::Service)
// Use fallback_service for services like ServeDir
#[derive(Clone)]
struct FallbackService;
 
impl Service<axum::extract::Request> for FallbackService {
    type Response = axum::response::Response;
    type Error = std::convert::Infallible;
    type Future = std::future::Ready<Result<Self::Response, Self::Error>>;
    
    fn poll_ready(&mut self, _cx: &mut std::task::Context<'_>) -> std::task::Poll<Result<(), Self::Error>> {
        std::task::Poll::Ready(Ok(()))
    }
    
    fn call(&mut self, request: axum::extract::Request) -> Self::Future {
        let response = axum::response::Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body(axum::body::Body::from("Service fallback"))
            .unwrap();
        std::future::ready(Ok(response))
    }
}
 
#[tokio::main]
async fn main() {
    // Handler fallback (easier for most cases)
    let app1 = Router::new()
        .route("/hello", get(hello))
        .fallback(handler_fallback);
    
    // Service fallback (for tower services)
    let app2 = Router::new()
        .route("/hello", get(hello))
        .fallback_service(FallbackService);
    
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app1).await.unwrap();
}

fallback() takes handlers; fallback_service() takes tower services.

Error Context in Fallback

use axum::{
    routing::get,
    Router,
    http::{StatusCode, Uri, header},
    response::IntoResponse,
    Json,
};
use serde::Serialize;
 
#[derive(Serialize)]
struct ErrorContext {
    error: String,
    path: String,
    request_id: String,
    documentation_url: String,
}
 
async fn contextual_fallback(
    uri: Uri,
    headers: axum::http::HeaderMap,
) -> impl IntoResponse {
    let request_id = headers
        .get("x-request-id")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("unknown");
    
    let error = ErrorContext {
        error: "Resource not found".to_string(),
        path: uri.to_string(),
        request_id: request_id.to_string(),
        documentation_url: "https://api.example.com/docs".to_string(),
    };
    
    (
        StatusCode::NOT_FOUND,
        [(header::CONTENT_TYPE, "application/json")],
        Json(error),
    )
}
 
async fn users() -> &'static str {
    "users"
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users", get(users))
        .fallback(contextual_fallback);
    
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

The fallback can extract headers and other request context for error reporting.

Fallback with Logging

use axum::{
    routing::get,
    Router,
    http::{StatusCode, Uri, Method},
    response::IntoResponse,
};
use tracing::{info, warn};
 
async fn hello() -> &'static str {
    "Hello"
}
 
async fn logged_fallback(method: Method, uri: Uri) -> impl IntoResponse {
    warn!(
        method = %method,
        path = %uri,
        "Route not found"
    );
    
    (
        StatusCode::NOT_FOUND,
        format!("{} {} not found", method, uri)
    )
}
 
#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();
    
    let app = Router::new()
        .route("/hello", get(hello))
        .fallback(logged_fallback);
    
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
// Logs: WARN Route not found method=GET path=/unknown

Fallback handlers are ideal for logging missing routes for monitoring.

Different Fallbacks for Different Routes

use axum::{
    routing::get,
    Router,
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use serde::Serialize;
 
#[derive(Serialize)]
struct ApiError {
    code: u16,
    message: String,
}
 
async fn api_users() -> &'static str {
    "Users"
}
 
async fn web_home() -> &'static str {
    "Home"
}
 
async fn api_404() -> impl IntoResponse {
    let error = ApiError {
        code: 404,
        message: "API endpoint not found".to_string(),
    };
    (StatusCode::NOT_FOUND, Json(error))
}
 
async fn web_404() -> impl IntoResponse {
    (
        StatusCode::NOT_FOUND,
        "<html><body><h1>404 - Page Not Found</h1></body></html>"
    )
}
 
#[tokio::main]
async fn main() {
    let api_router = Router::new()
        .route("/users", get(api_users))
        .fallback(api_404);
    
    let web_router = Router::new()
        .route("/", get(web_home))
        .fallback(web_404);
    
    let app = Router::new()
        .nest("/api", api_router)
        .nest("", web_router);  // Root-level web routes
    
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
// GET /api/users -> "Users"
// GET /api/unknown -> JSON 404 (api_404)
// GET / -> "Home"
// GET /unknown -> HTML 404 (web_404)

Different nested routers can have different fallback styles.

Synthesis

Fallback handler signatures:

Signature Use Case
async fn() Simple static 404
async fn(Uri) Include path in response
async fn(Method, Uri) Method-aware responses
async fn(Request) Full request access
async fn(HeaderMap, Uri) Header-dependent responses

Response patterns:

Pattern Use Case
(StatusCode, &str) Plain text error
(StatusCode, Json<Error>) API error response
(StatusCode, Html<String>) Styled HTML page
Redirect::to("/path") Redirect to valid page
StatusCode::NOT_FOUND Minimal 404

Router fallback methods:

Method Purpose
fallback(handler) Handler function for unmatched routes
fallback_service(service) Tower service for unmatched routes

Key insight: axum::routing::Router::fallback transforms the default "not found" response from a generic plain text message into a fully customizable handler with access to the entire request context. This enables API-consistent error responses where a JSON object with error code, message, path, and request ID provides clients actionable information rather than just "Not Found." For web applications, the fallback can return branded HTML pages matching the site's design. For SPAs, it can serve the index.html for client-side routing, allowing React/Vue/etc. to handle /users/123 on the client while the server just serves the same entry point. The fallback receives the original request with method, headers, URI, and body—everything a normal handler can extract—so it can log 404s with full context, redirect based on headers, or inspect the request body for specific error handling. Nested routers each support their own fallback, enabling /api routes to return JSON errors while /web routes return HTML, without any logic duplication. The pattern works with tower services too: fallback_service(ServeDir::new("static")) serves static files as a fallback for unmatched routes, useful for serving generated assets. This composability means the fallback is not just for error pages but for any "default route" behavior—redirects, proxying, static file serving, or request logging—making it a central piece of routing architecture rather than an afterthought for 404 pages.