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.
The project and its two dependencies
Section titled “The project and its two dependencies”cargo new shortlinkcd shortlinkcargo add axumcargo add tokio --features macros,rt-multi-threadUnder a current toolchain this writes an edition-2024 project (written and tested against Rust 1.99.0) and lands on these versions:
[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.
The whole server
Section titled “The whole server”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:
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 stris an ordinary function. Axum’s deal is that any function whose arguments are extractors and whose return type isIntoResponsecan be a handler, and&'static stris one of the types it converts for you — into a200withcontent-type: text/plain, which the curl below will show. - The router.
Router::new().route("/health", get(health))maps the path/healthwith the method GET to that function. Everything axum serves flows through aRouter; by the final step this chain has five routes on it. - The attribute.
mainisasync fn, and Rust cannot start an async function on its own — something has to poll the futures.#[tokio::main]rewritesmaininto 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.0is 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).awaitthen 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 defaulthelp: 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.
Start it and ask it something
Section titled “Start it and ask it something”cargo runCargo 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:
curl -i http://127.0.0.1:8000/healthHTTP/1.1 200 OKcontent-type: text/plain; charset=utf-8content-length: 2date: Sat, 17 Oct 2026 23:08:59 GMT
okThe 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:
curl -i http://127.0.0.1:8000/nopeHTTP/1.1 404 Not Foundcontent-length: 0date: Sat, 17 Oct 2026 23:08:59 GMTNo 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.
When the port is taken
Section titled “When the port is taken”Start a second copy while the first is still running:
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 backtraceThe 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.
Where this leaves the service
Section titled “Where this leaves the service”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.