Intro
We are going to introduce you HTMX with the rust server framework axum, which enables you to write reactive web applications with ease without the need of javascript frameworks.
You can get a good grasp by looking at fireship's 100s of HTMX video.
First Workshop Part
In this first part we will show you some basics with axum, rendering html with askama and using HTMX.
In detail the first part consists of:
- Serving with axum.
- How to return html directly using askama and how askama works.
- How to add HTMX to our html and how HTMX works with small examples.
After you got the basics with server side rending and using htmx and having a good lunch, we will hop on to the second part.
Second Workshop Part
In the second part of the workshop you will build something yourself, without further guidance. Of course we will help if you get stuck somewhere. Getting your hands dirty is the best way to learn.
You can mix and match server frameworks and rendering engines.
General
There are plenty of web application frameworks out there. Other big examples are warp, actix-web, or rocket.
For our workshop, we will use axum, the web framework created by the folks from tokio, as we have the most knowledge about with it (If you get stuck and e.g. need help).
But you can translate features, that axum offers also to the other frameworks in the one or the other way. In the end the only thing you need is a framework that is capable of sending html over the wire and process incoming http requests, which all of the above frameworks are capable of.
Axum
Axum, the web framework created by the folks from tokio.
What does axum feature, or set them apart from other frameworks:
- Macro free.
- Parse requests using extractors. E.g. to extract JSON in a handler.
- Has no own middleware - relies on integrating tower and tower-http and thus making it quite modular.
So what will we show you, that you will need later on:
- How to write a handler in axum and return data as http response
- How to extract a payload from an incoming request (Extractors)
- How to add state to your application
How to add Handlers
Below is a small boilerplate in axum, which we examine, on how to write a handler in axum:
use axum::{response::IntoResponse, routing::get, Router}; async fn hello_world_handler() -> impl IntoResponse { "Hello World" } #[tokio::main] async fn main() { let router = Router::new().route("/", get(hello_world_handler)); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); axum::serve(listener, router).await.unwrap(); }
As everything is asynchronous, we will need an asynchronous runtime, that executes our async functions. We will use tokio for this (as axum is also from the same guys).
A handler in axum is just an ordinary asynchronous free function (hello_world_handler
), that returns something, that implements IntoResponse
. IntoResponse
is a trait (Rust's interfaces) that tell the compiler, that this thing is convertible into a response.
To look what this means, you would have to look into the definition of IntoResponse
. Fow now, it's sufficient to know, that it returns a payload and a status code.
For this example our handler returns a string literal, which will be converted in a http response of with payload of type text/plain
with status code 200.
If you want to run the code, you can go to project in the snippets directory snippets/axum/handlers
and run the project with
cargo run
.
How extraction works in axum
In the chapter before you are only able to answer to client requests, that do not contain any payload. But there must be a way to send our small little server a payload.
For example:
- A server, that has some sort of user management, you want to add a new person to your database at some point in time.
This is done by so called extractors. The incoming request is parsed, and if it for example contains a JSON payload, you have a JSON extractor, that parses that JSON. After parsing you then can work on the data contained in the JSON.
There are also different extractors, which are ready to use. That would be for example:
- For queries: e.g.
/person?id=1234
- For paths: e.g.
/person/1
, where the1
would be extracted from the path - For forms: You can directly parse a form in a handler - that will come handy later for our workshop.
You can also write your own extractor, if you need to. But this is out of scope for now (If you are interested, have a look at axum's docs.
Extracting Json
Below is a small snippet, where we explain, how for example a JSON extractor in axum looks like, that extracts the JSON payload from the incoming request.
use axum::{response::IntoResponse, routing::post, Json, Router}; use serde::{Deserialize, Serialize}; #[derive(Deserialize)] struct PersonToAdd { name: String, } #[derive(Serialize)] struct Person { id: i32, name: String, } async fn add_person_handler(Json(person_to_add): Json<PersonToAdd>) -> impl IntoResponse { // let's just echo the incoming json // usually you would insert this person now into your database // ... let imaginary_id = 42; let person = Person { id: imaginary_id, name: person_to_add.name, }; Json(person) } #[tokio::main] async fn main() { let router = Router::new().route("/person", post(add_person_handler)); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); axum::serve(listener, router).await.unwrap(); }
If you look closely, only the handler function and the route changed in our axum's main
function.
To be detailed our new route
expects a JSON payload, that is deserializable into PersonToAdd
.
To send it fitting paylod, we can use curl, as CLI tool, with which we can send http request.
curl --header "Content-Type: application/json" --request POST --data '{"name":"Hans"}' http://localhost:3000/person
As with the hello_world_handler from before, we can return anything from a handler, that implements IntoResponse, which axum's Json<T>(T)
is doing.
We just echo the payload inside the handler and add an id
to it, which a normal server would do, after creating a ressource in a database to confirm creation of the new ressource.
Task
You have seen above, how you can echo a Json. Let's do it - but with a form this time!
Write a handler, that accepts a form instead of a Json - and just echoes the data you have provided. You can go to
snippets/axum/extraction
. There is already the boilerplate for you at hand.
- Go ahead and try to implement the following function
#![allow(unused)] fn main() { // TODO: You have to implement this one :) async fn my_form(Form(form): Form<MyForm>) -> impl IntoResponse { todo!() } }
- If you think you implemented it correctly, you can run
cargo test
.
Hint: You can also provide form data with curl with the following command:
curl -X POST -d "name=SomeName&age=44" localhost:3000/submit
State
In this chapter we look how you add state to your webserver. Then we'll look how you can access this state in your handlers.
How to add state
What's a webserver worth, if it cannot handle state?
In this example we will fill a HashMap
of people (from the previous chapter)
and add a query to fetch all - and fetch one. That will be your task.
To add state in axum we have to options:
State
: it's type safe - will fail at compile timeExtension
: Not typesafe - i.e will fail at runtime
We will use State here. To add a state, you can call the .with_state()
at the
end of your Router Builder
. We will just put a HashMap for learning reason (It's wrapped in an Arc<Mutex<_>>
, so we can share it between threads). Usually you would
give a handle to a database pool - which the handler then can use to
make queries and insertions to a database.
#[tokio::main] async fn main() { let persons: HashMap<String, Person> = HashMap::new(); let in_memory_db = Arc::new(Mutex::new(persons)); let router = Router::new() .route("/persons", post(add_person_handler)) .with_state(in_memory_db); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); axum::serve(listener, router).await.unwrap(); }
How to access (and mutate) state
With axum's extraction logic, you can also extract State
in the same way in the handler like a Json
extractor.
Now you can really add persons in the add_person_handler
, like the following:
#![allow(unused)] fn main() { async fn add_person_handler( State(db): State<InMemoryDb>, // <-- State extractor - here we get the state Json(person_to_add): Json<PersonToAdd>, ) -> impl IntoResponse { let mut db_lock = db.lock().await; // <-- Get the mutex lock let uuid = Uuid::new_v4(); let person = Person { uuid: uuid.to_string(), name: person_to_add.name, }; db_lock.insert(uuid.to_string(), person.clone()); // <-- Insert the person Json(person) } }
Let's write a small handler to receive all of our current person - but this time as a small challenge for you ;).
Your Task
- Go into the project
snippets/axum/state
and open your IDE of choice there. - Write a handler, that returns a list of
Person
as Json, when you callGET /persons
. Of course the list will only be filled up, if you posted some persons - We already gave you a small starter here:
#![allow(unused)] fn main() { // TODO: This is your task ;) pub async fn get_all_persons_handler(State(db): State<InMemoryDb>) -> impl IntoResponse { todo!() } }
- run
cargo test
to verify you implemented the handler correctly
Hint: You can also test your webserver afterwards with cargo run
. To add persons with this curl command:
curl --header "Content-Type: application/json" --request POST --data '{"name":"Hans"}' http://localhost:3000/persons
(Optional) Dependency Injection/Inversion with Axum
As of now we added our state directly into our web project. For greater projects it usually makes sense to abstract details away. In the end it should not matter, how your state is stored. It might be an SQL like database, a file, or something else.
In the end you are only interested in "storing" your state. But how - is a matter of detail, that we are now going to abstract away.
Currently, we have this dependency tree (left) - but we want to invert the dependency (right):
Here you do not have to code anything - we just wanted you to show, how a production style
rust webserver could look like. The code for it is in snippets/injection
. You can run the unit tests with
cargo test
, or run the webserver by cargo run
.
Turning around dependencies
Our goal would be to turn around the dependency, such that our business logic does not depend on the implementation of the storage. Rather it should depend on an interface. The implementation then should depend on that same interface as well. This way, we can decouple our business logic from the implementation of our storage.
In Rust you usually achieve this behaviour with Traits.
First we define our trait, that's our interface layer between the storage implementation and anything else (e.g. our business logic):
#![allow(unused)] fn main() { #[async_trait] pub trait Repository { async fn add_user(&mut self, user: &UserToCreate) -> User; async fn remove_user(&mut self, id: i64) -> Option<User>; async fn get_users(&self) -> Vec<User>; } }
Next, we can then implement a struct, that wraps our details and implements the above trait.
We implement an SqliteAdapter
- wrapping an SqlitePool
and implementing above interface.
#![allow(unused)] fn main() { #[derive(Clone)] pub struct SqliteAdapter { pool: SqlitePool, } }
#![allow(unused)] fn main() { #[async_trait] impl Repository for SqliteAdapter { async fn add_user(&mut self, user: &UserToCreate) -> User {..} async fn remove_user(&mut self, id: i64) -> Option<User> {..} async fn get_users(&self) -> Vec<User> {..} } }
How do you now use this trait in axum handlers?
To make use of our new trait, we have to make them known to our handlers (where we
usually have our business logic). Instead of using a struct inside our State
extractor, we can
use the trait with static dispatch (generics):
#![allow(unused)] fn main() { pub async fn get_users<T: Repository>(State(repo): State<T>) -> impl IntoResponse { Json(repo.get_users().await) } }
Now our inner logic (turning things into a Json), does not depend on the implementation, but rather on the interface, that promises to return a list of users.
As axum heavily depends on generics, it's a bit harder to tell the compiler, that we want to make use of this trait. Structs, that implement our trait, also must implement more traits to be used in a "generic" router.
To be correct they must implement: Clone + Send + Sync + 'static
. For example our
Arc<Mutex<HashMap<_,_>>>
would implement all of these above. For our SqliteAdapter
Send + Sync
is implement be the wrapped SqlitePool
. This is why have to declare our
SqliteAdapter
to by Clone
. Our structures are automatically 'static
, as we will create
them in the beginning of our program (see chapters below) - and we guarantee, they outlive all places where they are used.
TL;DR: Our router (that defines the routes, and uses our handlers) needs more guarantees to be generic We have to make more guarantees, so we can use the interface we defined. A generic router looks like this now:
#![allow(unused)] fn main() { pub fn routes<T: Repository + Clone + Send + Sync + 'static>() -> Router<T> { Router::new() .route("/users", get(get_users::<T>).post(add_user::<T>)) .route("/users/:id", delete(remove_user::<T>)) } }
How (and where) do you dependency inject the implementation?
After we have a trait layer between our storage implemention, the next question is,
where do we now inject our SqliteAdapter
into our program?
The answer is rather simple. We just give our with_state(...)
method in the router builder
our SqliteAdapter
. The compiler is happy, as SqliteAdapter
meets all guarantees, we promised
our compiler.
We inject like this:
#![allow(unused)] fn main() { let pool = Sqlitepool:new()...;// create an sqlitepool from sqlx let adapter = SqliteAdapter::new(pool).await; let router = Router::new().merge(user::routes()).with_state(adapter); }
Askama
Askama is a popular Rust templating engine inspired by Jinja2 and Twig. It focuses on simplicity, safety, and efficiency by compiling templates into pure Rust code at build time. In this chapter, we will explore what makes Askama unique and learn how to use it effectively.
<head>
<title>{{ title|upper }}</title>
</head>
<body>
<h1>Hello, {{ user.name }}!</h1>
{% if user.is_admin %}
<p>Welcome, Admin!</p>
{% else %}
<p>Welcome, User!</p>
{% endif %}
<ul>
{% for role in user.roles %}
<li>{{ role }}</li>
{% endfor %}
</ul>
</body>
</html>
Template Engines
Template engines are tools used to generate dynamic content (mostly, but not exclusively HTML). They generate the content by combining static templates with runtime data. Historically, they evolved out of server side includes and PHP, where embedding logic directly in HTML became a standard practice for creating dynamic web pages. Over time, modern template engines emerged with cleaner syntax, better safety features, and more structured approaches to templating. They provide various properties that contribute to their usability and functionality:
Safety Through Auto-Escaping
Modern template engines like Askama ensure safety by escaping special characters in user input. This prevents common security issues like Cross-Site Scripting (XSS) in web applications. By default, Askama escapes potentially unsafe characters when rendering HTML templates.
#![allow(unused)] fn main() { let user = User { name: "Alice", bio: "<script>alert('Hacked!')</script>", }; }
Scripts are by default escaped so they are not executed. {{ bio }}
will be rendered like this:
<script>alert('Hacked!')</script>
Minimal Set of Control Syntax
Template Engines provide a concise and minimal syntax for common control structures like loops, conditionals, and filters. This reduces boilerplate and improves template readability.
Simplicity and Composability
Templates are meant to be simple and composable. Template Engines typically support features like includes and inheritance, which make it easy to break large templates into smaller, reusable pieces.
Properties of Askama
Compiled Templates
Askama compiles templates into Rust code at build time. This approach ensures type safety, better performance, and early detection of syntax errors. Unlike interpreted engines like Tera, Askama doesn’t evaluate templates at runtime, making it faster and less error-prone at runtime. Tera, on the other hand, guarantees a more rapid development cycle especially for more complex projects, exactly because it can get around the compilation step.
Inspiration from Jinja2 and Twig
Askama’s syntax is heavily inspired by Jinja2 (Python) and Twig (PHP). Developers familiar with these engines will find Askama’s syntax intuitive and easy to adopt. The same is true for Tera.
Feature | Askama | Jinja2 |
---|---|---|
Variable interpolation | Hello, {{ name }}! | Hello, {{ name }}! |
Conditionals | {% if is_logged_in %}Welcome!{% else %}Please log in.{% endif %} | {% if is_logged_in %}Welcome!{% else %}Please log in.{% endif %} |
Loops | {% for item in items %}<li>{{ item }}</li>{% endfor %} | {% for item in items %}<li>{{ item }}</li>{% endfor %} |
Includes | {% include "header.html" %} | {% include "header.html" %} |
Template inheritance | {% extends "base.html" %}{% block content %}Hello{% endblock %} | {% extends "base.html" %}{% block content %}Hello{% endblock %} |
Filters | {{ name\|upper }} | {{ name \| upper }} |
Macros | {% macro greet(name) %}Hello, {{ name }}!{% endmacro %} | {% macro greet(name) %}Hello, {{ name }}!{% endmacro %} |
Match-like structures | {% match status %}{% when "success" %}OK{% else %}Error{% endmatch %} | |
Rust-specific syntax | {% if let Some(value) = optional %}{{ value }}{% endif %} |
How-to
This section provides a step-by-step guide to using Askama in your projects.
Include Askama in Your App
Add Askama to your Cargo.toml
file:
[dependencies]
askama = "0.12"
Locate Templates in Your project
Templates are stored in a templates directory by convention.
File extensions like .html
or .txt
indicate the template's format.
For example, templates/hello.html
:
<!DOCTYPE html>
<html>
<head>
<title>Hello, {{ name }}!</title>
</head>
<body>
<h1>Welcome, {{ name }}!</h1>
</body>
</html>
Use Variables / Render Templates
Pass data to templates using Rust structs:
use askama::Template; #[derive(Template)] #[template(path = "hello.html")] struct HelloTemplate<'a> { name: &'a str, } fn main() { let hello = HelloTemplate { name: "Alice" }; println!("{}", hello.render().unwrap()); }
Architect with Template Inheritance / Blocks
Template inheritance allows defining base layouts and extending them with specific blocks. For example:
<!-- templates/base.html -->
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}Default Title{% endblock %}</title>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>
<!-- templates/home.html -->
{% extends "base.html" %}
{% block title %}Home{% endblock %}
{% block content %}
<h1>Welcome to the home page!</h1>
{% endblock %}
Compose with Includes
Use {% include %}
to embed reusable components:
<!-- templates/base.html -->
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}Default Title{% endblock %}</title>
</head>
<body>
{% include "header.html" %}
{% block content %}{% endblock %}
{% include "footer.html" %}
</body>
</html>
Use Loops and Conditionals
Render dynamic content using loops and conditionals:
<ul>
{% for item in items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
{% if condition %}
<p>Condition is true!</p>
{% else %}
<p>Condition is false!</p>
{% endif %}
Rust-Specific if let and match
Askama supports Rust's if let
and match
expressions for more complex logic:
{% if let Some(value) = optional %}
<p>Value: {{ value }}</p>
{% else %}
<p>No value found.</p>
{% endif %}
{% match status %}
{% when "success" %}<p>Success!</p>
{% when "error" %}<p>Error occurred.</p>
{% else %}<p>Unknown status.</p>
{% endmatch %}
Use Filters
Filters process variables for display:
<p>{{ message|upper }}</p>
Note that the pipe symbol must not be surrounded by spaces; otherwise, it will be interpreted as the BitOr operator.
Register Your Own Filters
Custom filters can be registered in Rust code:
#![allow(unused)] fn main() { mod filters { pub fn reverse(s: &str) -> askama::Result<String> { Ok(s.chars().rev().collect()) } } }
Full Example: Integrating Askama with Axum
Step 1: Template Definition
Create an Askama template file, templates/hello.html
:
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
</head>
<body>
<h1>Hello, {{ user.name }}!</h1>
</body>
</html>
Define the corresponding Rust struct for the template:
#![allow(unused)] fn main() { use askama::Template; #[derive(Template)] #[template(path = "hello.html")] pub struct HelloTemplate<'a> { pub title: &'a str, pub user: User<'a>, } pub struct User<'a> { pub name: &'a str, } }
Step 2: Axum Handler with impl IntoResponse
Set up an Axum handler that renders the Askama template:
#![allow(unused)] fn main() { use axum::{ response::{IntoResponse, Response}, routing::get, Router, }; use askama::Template; // Bring in the Template trait for rendering. use crate::{HelloTemplate, User}; async fn hello_handler() -> impl IntoResponse { let user = User { name: "Alice" }; let template = HelloTemplate { title: "Hello Page", user, }; match template.render() { Ok(html) => Response::builder() .header("Content-Type", "text/html") .body(html) .unwrap(), Err(err) => { eprintln!("Template rendering error: {}", err); Response::builder() .status(500) .body("Internal Server Error".into()) .unwrap() } } } }
Step 3: Set Up Axum Application
Create an Axum app and route the handler:
#[tokio::main] async fn main() { let app = Router::new().route("/", get(hello_handler)); axum::Server::bind(&"127.0.0.1:3000".parse().unwrap()) .serve(app.into_make_service()) .await .unwrap(); }
Why htmx?
You are doing REST wrong
I am getting frustrated by the number of people calling any HTTP-based interface a REST API. Today’s example is the SocialSite REST API. That is RPC. It screams RPC. There is so much coupling on display that it should be given an X rating. What needs to be done to make the REST architectural style clear on the notion that hypertext is a constraint? In other words, if the engine of application state (and hence the API) is not being driven by hypertext, then it cannot be RESTful and cannot be a REST API. Period. Is there some broken manual somewhere that needs to be fixed?
–Roy Fielding, Creator of the term REST
Users do not understand Json
No, but the browser can convert:
<button hx-post="/clicked"
hx-trigger="click"
hx-target="#parent-div"
hx-swap="outerHTML"
>
Click Me!
</button>
to a Button
Attributes over javascript
With javascript:
<button id="my-button" on-click="clicked()">
Click Me!
</button>
<script>
function clicked(){
const request = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
const element = document.getElementById("parent-div");
element.outerHtml = xhr.response
}
};
request.open('POST', "/clicked");
request.send(null);
}
</script>
With Htmx:
<button hx-post="/clicked"
hx-trigger="click"
hx-target="#parent-div"
hx-swap="outerHTML"
>
Click Me!
</button>
This was a simple example, but we already see how concise Htmx can be.
Now infinite scroll:
With javascript:
<tr
<td>Agent Smith</td>
<td>void29@null.org</td>
<td>55F49448C0</td>
</tr>
<script>
//... yeah not going to do that
</script>
With Htmx:
<tr hx-get="/contacts/?page=2"
hx-trigger="revealed"
hx-swap="afterend">
<td>Agent Smith</td>
<td>void29@null.org</td>
<td>55F49448C0</td>
</tr>
HTMX samples
Simple Button
<h1>HTMX Example</h1>
<button hx-get="/message" hx-target="#message">Click me!</button>
<div id="message"></div>
Form
<h1>HTMX Example</h1>
<form
hx-post="/message"
hx-target="#message"
>
<input type="text" name="message" placeholder="Enter your message">
<button type="submit" >Click me!</button>
</form>
<div id="message"></div>
Composition
<h1>Example</h1>
<div hx-get="/component1" hx-trigger="load delay:2s">
</div>
<h1>HTMX Component</h1>
<div>
<p>Component 1</p>
<button hx-get="/message" hx-target="#message" >
Get message
</button>
<div id="message"></div>
</div>