Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Hello HTML World

In the previous version of the standard “Hello World” application we sent some HTML, but the content type was set by axum to be text/plain and thus the browser showed the HTML tags in their natural beauty without renedring them.

Let’s improve that.

You can reuse the same crate or create a new one. It is up to you.

This is how our Cargo.toml file looks like. I think besides the name, it is the same as in the previous example.

[package]
name = "hello-html-world"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]
axum = "0.8.8"
tokio = { version = "1.50.0", features = ["full"] }

[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }

The code

Let’s only focus on the differences from the previous example.

We are also going to use the Html struct from axum::response so we import it:

#![allow(unused)]
fn main() {
use axum::{response::Html, routing::get, Router};
}

In the function that handles the request we wrap the result in the Html struct. We need to do it both in the signature of the function and in the actual return value.

#![allow(unused)]
fn main() {
async fn handle_main_page() -> Html<&'static str> {
    Html("<h1>Hello, World!</h1>")
}
}

All the rest is the same.

To run the application type in the following command:

$ cargo run

This will compile the code, run the server and print the following to the terminal:

listening on http://127.0.0.1:3000

You can then open your browser and visit that address.

You should see Hello World! in big letters.

Handling other pages

If you try to visit some other page on your server e.g. http://127.0.0.1:3000/hi you will get a blank page. We’ll see what happens there and how to make axum to display a custom 404 error page.

Checking with curl

The curl command allows you to access web sites from the command line. It is a very handy tool.

Let’s see how can we use it with our web site.

Accessing the main page:

$ curl http://localhost:3000/
<h1>Hello, World!</h1>

We can also observe what happens if we try to access a page that does not exist. It seems that nothing happens which is rather inconvenient. This is the blank page we saw earlier.

$ curl http://localhost:3000/hi

Using the -i flag we can ask curl to also display the HTTP header the server sent us. Using the upper-case -I flag curl would print only the header that was sent by the server. It might be more convenient.

Accessing the main page we get the following:

$ curl -I http://localhost:3000/
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 22
date: Fri, 14 Mar 2025 08:27:44 GMT

<h1>Hello, World!</h1>

The first line includes the status code. This time it is 200 OK success status.

Accessing a page that does not exists we get a 404 Not Found error status.

$ curl -I http://localhost:3000/hi
HTTP/1.1 404 Not Found
date: Fri, 14 Mar 2025 08:27:41 GMT

Shutting down our local server

Once you are done with this experiment you will want to shut down this local web server. Return to the terminal where you ran it and press Ctrl-C.

Editing this example

Feel free to edit this example and see what happens. However, remember that after each change you’ll need to stop the server and start it again. This is rather inconvenient. Later we’ll see how to make Rust automatically recompile and restart the server every time to make some changes.

Improving the 404 page

You might dislike the fact visiting a non-existent path returns a blank page.

Check out the example showing the 404 handler.

The full example

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

async fn handle_main_page() -> Html<&'static str> {
    Html("<h1>Hello, World!</h1>")
}

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

#[tokio::main]
async fn main() {
    let app = create_router();

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();
    println!("listening on http://{}", listener.local_addr().unwrap());
    axum::serve(listener, app).await.unwrap();
}

#[cfg(test)]
mod tests;

tests.rs

#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;

use super::*;

#[tokio::test]
async fn test_main_page() {
    let response = create_router()
        .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);
    let body = response.into_body();
    let bytes = body.collect().await.unwrap().to_bytes();
    let html = String::from_utf8(bytes.to_vec()).unwrap();

    assert_eq!(html, "<h1>Hello, World!</h1>");
}

#[tokio::test]
async fn test_missing_page() {
    let response = create_router()
        .oneshot(
            Request::builder()
                .uri("/other")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::NOT_FOUND);
    let body = response.into_body();
    let bytes = body.collect().await.unwrap().to_bytes();
    let html = String::from_utf8(bytes.to_vec()).unwrap();

    assert_eq!(html, "");
}
}