Rust Web Server with Axum 0.8: Your First Server
October 25, 2026
This is Episode 1 of the Rust Backend series, where we build the backend of a blog step by step. Today: our very first web server with Axum 0.8 and Tokio. Everything from the video is below, ready to copy.
New to Rust? Start with the beginner series. The full backend series playlist is here.
1. Create the project
cargo new blog_api
cd blog_api2. Add the crates
Axum is the web framework, and Tokio runs async code.
cargo add axum
cargo add tokio --features fullYour Cargo.toml now has axum 0.8 and tokio with all features.
3. A handler and a router
A handler is an async function that answers a request. The router connects a path to a handler. Put this in src/main.rs:
use axum::{routing::get, Router};
async fn main() {
let app = Router::new().route("/", get(hello));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Listening on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
async fn hello() -> &'static str {
"Hello from Rust!"
}4. Error E0752: main is not allowed to be async
Run cargo run and you get this error:
error[E0752]: `main` function is not allowed to be `async`
--> src/main.rs:3:1
|
3 | async fn main() {
| ^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`Rust has no built-in async runtime. That is Tokio's job.
5. The fix: #[tokio::main]
Add the attribute above main. It starts the Tokio runtime and runs the async main inside it.
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(hello));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Listening on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
async fn hello() -> &'static str {
"Hello from Rust!"
}Run it again. You see Listening on http://localhost:3000. Open http://localhost:3000 in the browser and you get Hello from Rust!. Press Ctrl + C in the terminal to stop the server.
6. A route with a path parameter
Path is an extractor: it takes the id from the URL and turns it into a number.
Many older tutorials write the parameter with a colon, like this:
.route("/posts/:id", get(get_post))In Axum 0.8 that panics at startup:
Path segments must not start with `:`. For capture groups, use `{capture}`.Path parameters now go in curly braces. This is the final, working code:
use axum::{extract::Path, routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(hello))
.route("/posts/{id}", get(get_post));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Listening on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
async fn hello() -> &'static str {
"Hello from Rust!"
}
async fn get_post(Path(id): Path<u32>) -> String {
format!("Post number {id}")
}7. Test it with curl
Start the server with cargo run, then use a second terminal. The -i flag also shows the status code.
curl -i localhost:3000/posts/5HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 13
Post number 5Send letters instead of a number. The extractor cannot turn abc into a u32, so Axum answers 400 Bad Request for us:
curl -i localhost:3000/posts/abcHTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
content-length: 42
Invalid URL: Cannot parse `abc` to a `u32`A path we never defined gives 404 Not Found:
curl -i localhost:3000/nothingHTTP/1.1 404 Not Found
content-length: 0Recap
- A handler is an async function that answers a request.
- The router connects a path to a handler.
#[tokio::main]starts the async runtime.Pathextracts a value from the URL.- Three kinds of errors: a compile error (E0752), a panic at startup (
:id), and HTTP errors (400 and 404).
Tested with Rust 1.99.1, axum 0.8.9 and tokio 1.54.0.
Next episode: JSON in and out with Serde. Subscribe so you do not miss it.