Updated Aug 27, 2026

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: abc123

Key Takeaways

  • Handlers are plain async functions — no traits to implement, no macros
  • Router::new().route(path, method(handler)) registers a route
  • :param in the path captures a segment; Path<T> extracts it
  • Query<T> deserializes query string parameters using Serde
  • Anything implementing IntoResponse can be returned from a handler
  • Tuples like (StatusCode, body) implement IntoResponse out 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?

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy