The router is your application's entry point
You have a function that returns a JSON response. You have another function that reads a file from disk. Now you need the server to pick the right function based on the URL and the HTTP method. Axum doesn't use decorators or global registries. You build a router by chaining methods, creating a typed map of paths to handlers. The router decides which handler runs for each request.
Think of the router as a restaurant host. Guests arrive with a request, which includes the URL and the method. The host checks the reservation book and sends the guest to the correct table. If no reservation matches, the host tells them to leave. In Axum, the Router type is that host. You populate the book by calling .route() or method helpers like .get(). The book is built at startup, so lookups are fast.
Build the router first. Everything else hangs off it.
How routing works
Axum routes are immutable. When you call .route() on a Router, you don't modify the existing router. You get a new Router with the route added. This builder pattern lets you construct the router in steps, pass it around, and merge pieces without side effects.
A route consists of a path pattern and a method matcher. The path can be static, like /users, or contain parameters, like /users/{id}. The method matcher checks the HTTP method. Axum provides helpers for common methods: get, post, put, delete, patch, head, options. You can also match any method with any.
Handlers are just async functions. Axum extracts data from the request using extractor types. The handler's return value must implement IntoResponse, which turns it into an HTTP response. Axum provides extractors for path parameters, query strings, JSON bodies, headers, and more. It also provides response types for JSON, text, HTML, and status codes.
Start with this pattern. It works for every Axum app.
Minimal example
This example creates a router with two routes and starts a server.
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
// Create the router and add routes.
let app = Router::new()
.route("/", get(root))
.route("/foo", get(foo));
// Bind to port 3000 and start serving.
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
/// Handles requests to the root path.
async fn root() -> &'static str {
"Hello, World!"
}
/// Handles requests to /foo.
async fn foo() -> &'static str {
"Hello, Foo!"
}
The Router::new() call creates an empty router. The .route("/", get(root)) call adds a route for the root path. The get function wraps the root handler in a type that Axum recognizes as a GET handler. The chain returns a new Router with the route added.
At runtime, axum::serve listens for TCP connections. When a request arrives, Axum matches the path and method against the routes. If it finds a match, it calls the handler. The handler's return value becomes the response body. If no route matches, Axum returns a 404 Not Found.
Realistic example with extraction
Real applications need path parameters and structured responses. This example extracts an ID from the URL and returns JSON.
use axum::{
extract::Path,
response::Json,
routing::{get, post},
Router,
};
use serde::Serialize;
#[tokio::main]
async fn main() {
// Build the router with typed path parameters and JSON responses.
let app = Router::new()
.route("/users/{id}", get(get_user))
.route("/users", post(create_user));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
/// Response structure for user data.
#[derive(Serialize)]
struct User {
id: u32,
name: String,
}
/// Extracts the `id` from the URL path.
async fn get_user(Path(id): Path<u32>) -> Json<User> {
Json(User {
id,
name: format!("User {}", id),
})
}
/// Handles user creation.
async fn create_user() -> &'static str {
"User created"
}
The Path<u32> extractor reads the id segment from the URL and parses it as a u32. If the URL is /users/123, id becomes 123. If the URL is /users/abc, Axum returns a 400 Bad Request because the parsing fails. The Json<User> response type serializes the User struct to JSON and sets the Content-Type header.
Convention is to use #[derive(Serialize)] and #[derive(Deserialize)] from serde for request and response bodies. This keeps the data structures separate from the HTTP layer. Handlers should extract data, call business logic, and return a response. Put the logic in separate modules. This makes testing easier and keeps the router clean.
Extractors turn HTTP data into Rust types. Lean on them.
Sharing state across handlers
Handlers often need access to shared data, like a database pool, configuration, or a cache. Axum uses the State extractor for this. You add state to the router with .with_state(). Then handlers extract it with State(state). This is type-safe. The compiler ensures the handler gets the correct state type.
use axum::{
extract::State,
routing::get,
Router,
};
#[tokio::main]
async fn main() {
// Create shared state.
let config = Config {
app_name: "MyApp".to_string(),
version: "1.0.0".to_string(),
};
// Add state to the router.
let app = Router::new()
.route("/info", get(info))
.with_state(config);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
/// Configuration shared across handlers.
struct Config {
app_name: String,
version: String,
}
/// Extracts state and returns app info.
async fn info(State(config): State<Config>) -> String {
format!("{} v{}", config.app_name, config.version)
}
The .with_state(config) call attaches the Config instance to the router. The State<Config> extractor retrieves it in the handler. You can add state to nested routers too. Child routers inherit state from parents. If a child router defines its own state, it overrides the parent's state for that type.
Convention is to use a struct for state, even if it has only one field. It makes adding fields later painless. If you start with a bare DatabasePool, you'll have to refactor every handler when you need to add Config. A struct lets you grow the state without touching handlers.
Wrap shared data in State. The compiler enforces access.
Pitfalls and compiler errors
Routing errors fall into two categories: compile-time type errors and runtime mismatches. The compiler catches handler signature errors. Runtime errors happen when the request doesn't match the routes.
If your handler returns a type that doesn't implement IntoResponse, the compiler rejects it. You'll see an error like E0277 (the trait bound Vec<u8>: IntoResponse is not satisfied). Fix this by wrapping the value in a response type like axum::response::Response or Json. If you return a String where a &'static str is expected, you get E0308 (mismatched types). Use String as the return type or convert the value.
If your handler arguments don't implement FromRequest, the compiler rejects it. Extractors must be valid. You can't just put any type in the argument list. The error message will tell you which type is missing the trait bound.
At runtime, if the client sends a POST to a route defined with get, Axum returns a 405 Method Not Allowed. You don't get a compile error here. The mismatch happens when the request arrives. If the path doesn't match any route, you get a 404 Not Found. You can customize the 404 response with .fallback().
Trust the type system for handler signatures. Handle 404s and 405s in your error policy.
When to use each routing pattern
Use Router::new() when you are starting a fresh application and need an empty router to build upon. Use .route(path, method) when you want to map a specific path and HTTP method to a handler function. Use method helpers like .get(), .post(), .put(), .delete() when you want concise syntax for common methods; these are just wrappers around .route() that make the code read better. Use .nest("/prefix", sub_router) when you have a group of routes that share a common path prefix; nesting keeps the main router flat and organizes related endpoints. Use .fallback(handler) when you want to catch all unmatched requests with a custom handler instead of the default 404 response. Use .merge(other_router) when you are combining two routers, such as merging a public API router with an admin router. Use .with_state(state) when you need to share data across multiple handlers. Use State extractor when a handler needs access to the shared state attached to the router.
Keep the router flat when possible. Nest only when the prefix is shared.