Getting Started

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against axum 0.8.9 on Rust 1.99 (edition 2024; axum itself needs Rust 1.80 or later), with tokio 1.53, checked on 2026-10-06. It creates the bookshelf-web crate that every page of this section extends. If you have not installed Rust yet, follow Rust: Getting Started first.

Where it runs in the request

In the request lifecycle (tokio accepts the connection, hyper parses the request, tower layers wrap the router, the router matches a route, middleware and extractors run, the handler runs, the response travels back out), the first service on this page touches the essential steps once: tokio::main starts the runtime, axum::serve drives hyper, the Router matches GET /health, the handler runs and its &'static str becomes a 200 OK text response. There is no middleware and no extractor yet; extractors appear in the compile-error example at the end of the page, and layers on later pages, between hyper and the router or around the matched route.

Creating the crate

Bookshelf lives in the same Cargo workspace as the shelf-core domain crate from the Rust Reference. From the workspace root:

$ cargo new --bin crates/bookshelf-web
$ cd crates/bookshelf-web
$ cargo add axum@0.8.9
$ cargo add tokio@1.53 --features full
$ cargo add serde@1 --features derive
$ cargo add tracing@0.1 tracing-subscriber@0.3

Source: cargo new and cargo add

The resulting manifest:

[package]
name = "bookshelf-web"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"

[dependencies]
axum = "0.8.9"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1.53", features = ["full"] }
tracing = "0.1"
tracing-subscriber = "0.3"

axum’s documentation lists its required dependencies: axum itself and tokio. You can trim tokio’s features to ["macros", "rt-multi-thread", "net"] (plus signal for graceful shutdown, time for timers, sync for channels) once the service stabilises; full is convenient while learning. tower and tower-http are added when the first middleware is (see tower-http Catalog).

Feature flags

Feature Default Enables

http1

yes

HTTP/1.1 support in axum::serve

http2

no

HTTP/2 (h2c and TLS-negotiated); needed for WebSockets over HTTP/2

json

yes

the Json extractor and response (serde_json)

form

yes

the Form extractor and response (serde_urlencoded)

query

yes

the Query extractor

matched-path

yes

the MatchedPath extractor and recording the matched route on requests

original-uri

yes

the OriginalUri extractor (the URI before nest stripped its prefix)

tokio

yes

axum::serve, ConnectInfo and other tokio integration

tower-log

yes

the log feature of tower

tracing

yes

internal tracing events, such as extractor rejections at TRACE level

macros

no

#[debug_handler], #[debug_middleware] and the derive macros from axum-macros

ws

no

WebSockets (axum::extract::ws)

multipart

no

the Multipart extractor

__private_docs

no

internal; never enable

A pure-library crate that only defines extractors can depend on axum-core with no features; an application usually adds macros, and ws/multipart only when it uses them, to keep compile times down.

The first service

use axum::{Router, routing::get};

async fn health() -> &'static str {
    "ok"
}

fn app() -> Router {
    Router::new().route("/health", get(health))
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .expect("failed to bind port 3000");
    tracing::info!("listening on {}", listener.local_addr().expect("bound socket has an address"));
    axum::serve(listener, app()).await.expect("server error");
}

Source: axum::serve

Line by line:

  • #[tokio::main] turns async fn main into a synchronous main that builds a multi-thread tokio runtime and blocks on the future. It is shorthand for tokio::runtime::Builder::new_multi_thread().enable_all().build()?.block_on(...) (see Async Runtimes & Tokio).

  • tokio::net::TcpListener::bind opens a non-blocking listening socket. Bind 0.0.0.0 (all interfaces) inside a container, where the port is published by the runtime; bind 127.0.0.1 on a laptop to keep the service private. Binding port 0 asks the OS for a free port, which tests use (listener.local_addr() reports it).

  • axum::serve(listener, app) accepts connections forever, spawning one task per connection, and returns a Serve future that only completes on an I/O error — or when graceful shutdown is configured with .with_graceful_shutdown(signal) (see Graceful Shutdown).

  • Router::new().route("/health", get(health)) registers an async function as the handler for GET /health. get builds a MethodRouter; chaining .post(...) on it adds more methods for the same path (see Routing).

  • A &'static str return value becomes a 200 OK response with content-type: text/plain; charset=utf-8.

axum::serve replaced axum::Server (a re-export of hyper 0.14’s server) in axum 0.7. It is intentionally simple: no TLS, no per-connection configuration. When you need those, either put a TLS-terminating proxy in front (the usual production choice) or drive hyper-util yourself (see TLS & HTTPS and Performance Tuning).

$ cargo run
   Compiling bookshelf-web v0.1.0 (/work/shelf/crates/bookshelf-web)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.81s
     Running `target/debug/bookshelf-web`
2026-10-06T09:12:44.512881Z  INFO bookshelf_web: listening on 0.0.0.0:3000

$ curl -i localhost:3000/health
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 2
date: Tue, 06 Oct 2026 09:12:51 GMT

ok
$ curl -i localhost:3000/nope
HTTP/1.1 404 Not Found
content-length: 0
date: Tue, 06 Oct 2026 09:12:58 GMT

$ curl -i -X POST localhost:3000/health
HTTP/1.1 405 Method Not Allowed
allow: GET,HEAD
content-length: 0
date: Tue, 06 Oct 2026 09:13:02 GMT

Source: axum::serve

The last two responses come from axum, not from your code: unmatched paths go to the router’s default fallback (404), and a matched path with an unsupported method gets 405 with an Allow header. A GET route also answers HEAD requests automatically, with the body stripped.

Configuring the address and port

Hard-coding 3000 is fine for a first run, but a deployable service reads its address from the environment (the full treatment is on the Configuration page):

use std::net::SocketAddr;

use axum::{Router, routing::get};

#[tokio::main]
async fn main() {
    let port: u16 = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(3000);
    let addr = SocketAddr::from(([0, 0, 0, 0], port));
    let app = Router::new().route("/health", get(|| async { "ok" }));

    let listener = tokio::net::TcpListener::bind(addr).await.expect("failed to bind");
    axum::serve(listener, app).await.expect("server error");
}

The closure || async { "ok" } is a valid handler too: any Clone + Send + Sync + 'static function or closure returning a Send future whose output implements IntoResponse qualifies (see Handlers).

Fast feedback while developing

A Rust service has to be recompiled and restarted after every change. Three tools shorten the loop:

  • bacon runs cargo check/clippy/test continuously in a terminal and shows the first error; it is the quickest way to get compiler feedback while editing. bacon run restarts the binary on success.

  • cargo-watch re-runs a cargo command on file changes (cargo watch -x run). Its maintainers consider it complete and point new users to bacon or watchexec; it still works.

  • listenfd + systemfd keep the listening socket open across restarts, so clients (and your browser tab) never see "connection refused" while the new binary starts. systemfd opens the socket and passes it to the child process; the service takes it over with listenfd and falls back to binding itself when run normally.

$ cargo install bacon systemfd cargo-watch
$ bacon clippy                                   # continuous lint feedback
$ systemfd --no-pid -s http::3000 -- cargo watch -x run   # auto-reload, socket kept open
use axum::{Router, routing::get};
use listenfd::ListenFd;
use tokio::net::TcpListener;

#[tokio::main]
async fn main() {
    let app = Router::new().route("/health", get(|| async { "ok" }));

    let mut listenfd = ListenFd::from_env();
    let listener = match listenfd.take_tcp_listener(0).expect("invalid LISTEN_FDS") {
        // Started by systemfd: reuse the already-open socket.
        Some(std_listener) => {
            std_listener.set_nonblocking(true).expect("cannot set non-blocking");
            TcpListener::from_std(std_listener).expect("cannot adopt socket")
        }
        // Started normally: bind as usual.
        None => TcpListener::bind("127.0.0.1:3000").await.expect("failed to bind"),
    };
    axum::serve(listener, app).await.expect("server error");
}

The hello-world and readme examples in the axum repository are the same minimal service as above.

Reading axum’s compiler errors

axum checks handlers with traits, so a mistake in a handler signature is reported where the handler is used (get(show_book)), not where it is defined, and the message names the Handler trait rather than the offending parameter:

use axum::{Router, routing::get};

async fn show_book(id: u32) -> String { // u32 is not an extractor
    format!("book {id}")
}

fn main() {
    let _app: Router = Router::new().route("/books/{id}", get(show_book));
    // error[E0277]: the trait bound `fn(u32) -> impl Future<Output = String> {show_book}: Handler<_, _>`
    //               is not satisfied
    //   = note: Consider using `#[axum::debug_handler]` to improve the error message
}

E0277 means "a trait bound is not satisfied". Adding #[axum::debug_handler] (with the macros feature) above the function makes axum generate one check per parameter and for the return type, so the error points at the real problem:

error[E0277]: the trait bound `u32: FromRequest<(), axum_core::extract::private::ViaParts>` is not satisfied
 --> src/main.rs:4:24
  |
4 | async fn show_book(id: u32) -> String {
  |                        ^^^ the trait `FromRequestParts<()>` is not implemented for `u32`
  |
  = note: Function argument is not a valid axum extractor.
          See `https://docs.rs/axum/0.8/axum/extract/index.html` for details

The fix here is Path(id): Path<u32>. The other usual causes — a body extractor that is not the last parameter, a non-Send value held across .await, a return type that does not implement IntoResponse, more than 16 parameters, or a state type that does not match the router’s — are explained, with their messages, on the Handlers and Extractors pages. debug_handler has no runtime cost, but it is common to add it only while fixing an error.

Where the books are outdated

Yoon (Beginning Axum, chapter 1) creates the project with edition 2021; new crates default to edition 2024 since Cargo 1.85, which changes nothing in this code but enables let chains and the newer temporary-scope rules used on later pages. Older tutorials start the server with axum::Server::bind(&addr).serve(app.into_make_service()); that API was removed in axum 0.7 in favour of axum::serve with a tokio TcpListener.