Skip to content
Select theme

Setting up Axum and your first route

Step 2 of 15, and the first code the track runs. It finishes with a process listening on port 8000 that answers exactly one question: curl http://127.0.0.1:8000/health comes back 200 with the body ok. That is the whole step — a project with two dependencies, sixteen lines of main.rs, and a server you can leave running while you read the rest of the track.

Step 1 committed nothing, so this starts from an empty directory. One choice is made for you below: the project is called shortlink. If you name yours something else, that is fine — just know that every path in this article that says target/debug/shortlink will say your name instead, and the curl commands stay the same either way.

Terminal window
cargo new shortlink
cd shortlink
cargo add axum
cargo add tokio --features macros,rt-multi-thread

Under a current toolchain this writes an edition-2024 project (written and tested against Rust 1.99.0) and lands on these versions:

Cargo.toml
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

The two features are not decoration. macros provides #[tokio::main], and rt-multi-thread provides the scheduler it boots by default. Leave rt-multi-thread out and the program stops compiling with an error that does not mention the feature name you forgot:

error: The default runtime flavor is `multi_thread`, but the `rt-multi-thread` feature is disabled.
--> src/main.rs:7:1
|
7 | #[tokio::main]
| ^^^^^^^^^^^^^^
|
= note: this error originates in the attribute macro `tokio::main` (in Nightly builds, run with -Z macro-backtrace for more info)

That error is about line 7 of main.rs — the attribute — because the attribute is what needs the feature. Keep both features and move on; the Tokio chapter has the full story on what each one switches on.

Replace src/main.rs with this. It is the entire service at this step, and it is the exact file the examples repository tags as api-ch02:

src/main.rs
use axum::{Router, routing::get};
async fn health() -> &'static str {
"ok"
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/health", get(health));
let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}

Four pieces, top to bottom:

  • The handler. async fn health() -> &'static str is an ordinary function. Axum’s deal is that any function whose arguments are extractors and whose return type is IntoResponse can be a handler, and &'static str is one of the types it converts for you — into a 200 with content-type: text/plain, which the curl below will show.
  • The router. Router::new().route("/health", get(health)) maps the path /health with the method GET to that function. Everything axum serves flows through a Router; by the final step this chain has five routes on it.
  • The attribute. main is async fn, and Rust cannot start an async function on its own — something has to poll the futures. #[tokio::main] rewrites main into a regular function that builds a Tokio runtime and runs your async body inside it.
  • The listener and axum::serve. TcpListener::bind("0.0.0.0:8000") claims port 8000 on all network interfaces — 0.0.0.0 is what a deployed container wants, and it costs nothing locally. Port 8000 is this track’s choice, not a law; pick another and the curl commands must match. axum::serve(listener, app).await then drives the server until the process is killed.

The two unwrap()s are deliberate. bind fails when the port is already taken, which this article shows for real in a moment; serve fails when the listener gives up. A service that cannot claim its port cannot exist, so panicking with the reason beats starting “successfully” and serving nothing.

One mistake worth meeting early, because its symptom is baffling: drop the .await on axum::serve and the program compiles — with one warning — then exits immediately and prints nothing:

warning: unused `Serve` that must be used
--> src/main.rs:12:5
|
12 | axum::serve(listener, app);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: futures must be awaited or polled
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
|
12 | let _ = axum::serve(listener, app);
| +++++++

axum::serve(...) returns a future. A future nobody awaits is a server nobody runs — the warning is the only trace. If cargo run ends silently the instant it starts, this is the first thing to check.

Terminal window
cargo run

Cargo prints its build lines, then Running \target/debug/shortlink``, and then — nothing. Axum prints no startup banner and does not announce the port. The process is listening; it just does not advertise. In a second terminal:

Terminal window
curl -i http://127.0.0.1:8000/health
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 2
date: Sat, 17 Oct 2026 23:08:59 GMT
ok

The date header will show your current time, not the one above. The lowercase header names are not a typo — that is the exact bytes on the wire; hyper writes header names in lowercase the way HTTP/2 requires, and HTTP/1.1 does not mind. The body is ok, two bytes, which is why content-length: 2.

Ask for a path the router has never heard of:

Terminal window
curl -i http://127.0.0.1:8000/nope
HTTP/1.1 404 Not Found
content-length: 0
date: Sat, 17 Oct 2026 23:08:59 GMT

No content-type, empty body — that is the Router’s default fallback. Nothing in your code decided this; the route simply does not exist yet. Step 3 starts filling the map in with routes that take path and query parameters.

Start a second copy while the first is still running:

Terminal window
cargo run
Running `target/debug/shortlink`
thread 'main' (11797944) panicked at src/main.rs:11:72:
called `Result::unwrap()` on an `Err` value: Os { code: 48, kind: AddrInUse, message: "Address already in use" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The number in parentheses is the thread id, and it will be different on your machine; 11:72 is the line and column of the unwrap() on the bind call, which is the same numbers you will see if you kept main.rs exactly as printed above. The process exits with status 101, the standard Rust panic exit code.

This is the crash being useful. Without it, the second copy would sit there “running” while every request quietly went to the first one. Request errors get proper HTTP treatment later in the track — step 10 — but startup stays blunt, and blunt is honest: if the port is gone, the service is dead.

The project now builds, runs, and answers one route. That end state is tagged api-ch02 in the examples repository — git checkout api-ch02 inside axum-sqlx-api/ in your clone puts your tree exactly where this article left it, and a diff against your own files shows which line drifted.

The /health answer is hardcoded for now — it proves the process is up. The check that reaches further than the process arrives with the database, in step 5. Before that, step 3 makes the routes take input: path segments like /links/:code and query strings like ?page=2, and the order extractors must appear in a handler’s signature. The track overview keeps the full list current as articles land.