02. First Route with Axum
📋 Jump to Takeaways🎁 Axum handlers are plain async functions. No special traits, no macros, no registration ceremony. Just a function with the right signature.
Routing
A Router maps HTTP methods and paths to handler functions:
use axum::{Router, routing::{get, post, delete}};
let app = Router::new()
.route("/links", get(list_links))
.route("/links", post(create_link))
.route("/links/:code", get(get_link))
.route("/links/:code", delete(delete_link));:code is a path parameter — captured automatically by Axum's Path extractor.
You can also chain multiple methods on a single .route() call — both forms are equivalent:
// same result
let app = Router::new()
.route("/links", get(list_links).post(create_link))
.route("/links/:code", get(get_link).delete(delete_link));Handlers
A handler is any async function that returns something Axum knows how to turn into a response. The simplest returns a string:
async fn health() -> &'static str {
"ok"
}Return a status code:
use axum::http::StatusCode;
async fn not_implemented() -> StatusCode {
StatusCode::NOT_IMPLEMENTED
}Return a tuple of status + body:
async fn created() -> (StatusCode, &'static str) {
(StatusCode::CREATED, "created")
}Axum implements the IntoResponse trait for all of these. Anything that implements IntoResponse can be returned from a handler.
Path Parameters
Use the Path extractor to capture URL segments:
use axum::extract::Path;
async fn get_link(Path(code): Path<String>) -> String {
format!("looking up code: {}", code)
}Path<String> destructures the extractor in the function signature — code is the captured value. For multiple parameters, use a tuple or a struct.
Query Parameters
use axum::extract::Query;
use serde::Deserialize;
#[derive(Deserialize)]
struct Pagination {
page: Option<u32>,
limit: Option<u32>,
}
async fn list_links(Query(params): Query<Pagination>) -> String {
let page = params.page.unwrap_or(1);
let limit = params.limit.unwrap_or(10);
format!("page={} limit={}", page, limit)
}Query<T> deserializes the query string into T using Serde. Missing optional fields become None.
Putting It Together
use axum::{Router, routing::{get, post}, extract::Path, http::StatusCode};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/health", get(health))
.route("/links/:code", get(get_link))
.route("/links", post(create_link));
let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn health() -> &'static str {
"ok"
}
async fn get_link(Path(code): Path<String>) -> String {
format!("code: {}", code)
}
async fn create_link() -> StatusCode {
StatusCode::CREATED
}curl http://localhost:3000/links/abc123
# code: abc123Key Takeaways
- Handlers are plain async functions — no traits to implement, no macros
Router::new().route(path, method(handler))registers a route:paramin the path captures a segment;Path<T>extracts itQuery<T>deserializes query string parameters using Serde- Anything implementing
IntoResponsecan be returned from a handler - Tuples like
(StatusCode, body)implementIntoResponseout of the box
🎁 Returning strings is fine for testing. But your API needs to return JSON — structured data the client can parse. How does Axum handle JSON, and how does Serde make it effortless?