Loading pageâŚ
Rust walkthroughs
Loading pageâŚ
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.
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.
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_404fallback() registers a handler for all unmatched routes.
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.
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.
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.
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.
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.
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 -> 404The fallback sees the method, enabling method-specific responses.
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.
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.
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.
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.
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=/unknownFallback handlers are ideal for logging missing routes for monitoring.
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.
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.