
Introduction
When most developers think about building a web application in 2025, the first ideas that come to mind usually involve large, complex JavaScript frameworks. React, Vue, Angular, and even newer players like Svelte are powerful tools, but they often come with significant overhead: long build times, complex toolchains, bloated bundles, and the constant maintenance of dependency chains. If all you want is a simple interactive feature—say, submitting a form without refreshing the page—these frameworks can feel like overkill.
That frustration is what drives many developers to explore alternatives. Over the past few years, a new pattern has gained momentum: server-driven interactivity. Instead of handing all the responsibility to the browser, why not let the server do most of the work, while the client simply renders HTML fragments as needed? This idea is at the heart of HTMX, a lightweight library that extends plain HTML with attributes like hx-post
or hx-get
. With HTMX, you can create dynamic and reactive experiences without writing pages of JavaScript.
But pairing HTMX with a strong backend matters. This is where Rust and Axum shine. Rust has become a favorite for developers who want both performance and safety. It allows you to write backend code that runs blazingly fast, scales with ease, and avoids common memory or concurrency pitfalls that plague other languages. Axum, one of the most popular Rust web frameworks, provides an ergonomic, async-first environment to build APIs and full web apps with minimal boilerplate. Unlike older, more rigid frameworks, Axum is modular and feels natural in Rust’s ecosystem.
Now imagine what happens when we combine the two approaches: Rust on the backend for speed and reliability, HTMX on the frontend for simplicity and interactivity. The result is a development workflow that avoids the heavy machinery of SPAs (single-page applications) while still delivering the smooth user experiences people expect today. You don’t need to bundle megabytes of JavaScript, wrestle with state libraries, or manage hydration mismatches. Instead, you get direct, predictable control over both the server and the client.
That’s exactly what we’re going to explore in this tutorial. We’ll build a Rust Axum HTMX todo app—a small project with a big lesson. On the surface, a todo list might look trivial, but it’s actually a perfect showcase for this stack. We’ll cover everything from setting up the development environment to wiring up HTMX attributes that update only parts of the page when new tasks are added. Along the way, you’ll see how natural it feels to create interactivity without JavaScript frameworks.
This tutorial isn’t just about code—it’s about a different way of thinking. Instead of defaulting to the “JavaScript everywhere” approach, you’ll learn how to keep your stack lean and focused. If you’re new to Rust, this project is an approachable entry point into web development with Axum. If you’re already a Rust developer, it’s a demonstration of how easily you can enhance your apps with modern front-end behavior. And if you’re someone who has grown tired of the ever-expanding complexity of front-end ecosystems, you’ll discover that the combination of Rust, Axum, and HTMX can feel like a breath of fresh air.
By the time you finish, you won’t just have a functioning todo application. You’ll walk away with the confidence to extend this pattern into larger projects—whether that means building an internal dashboard, a content management system, or even a full-fledged SaaS product. The Rust Axum HTMX todo app is small, but the ideas behind it scale remarkably well.
Table of Contents
Getting the Environment Ready
Before diving into building our Rust Axum HTMX todo app, we need to make sure our development environment is properly set up. This is a step many developers tend to rush through, but taking the time to carefully prepare your tools and dependencies will save you hours of debugging and frustration later. Think of it as laying a solid foundation for a house: if the base is weak, everything else will feel shaky. With Rust, the good news is that the ecosystem is mature, well-documented, and highly consistent across operating systems. Whether you’re using Linux, macOS, or Windows, the steps you’ll follow here are nearly identical.
Installing Rust with rustup
The first and most important step is ensuring you have the Rust toolchain installed. Rust is distributed via rustup
, which acts as both an installer and a version manager. This makes it easy to stay up-to-date with the latest stable releases while still allowing you to switch to beta or nightly builds if needed.
To install Rust, open your terminal (Command Prompt or PowerShell on Windows, Terminal on macOS, or your preferred shell on Linux) and run the following command:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
This will download and execute the official Rust installation script. During installation, you may be prompted to adjust your PATH environment variable so that cargo
, rustc
, and other Rust binaries are accessible globally. Make sure to accept this option; otherwise, you may need to manually add Rust’s bin directory to your PATH.
Once the installation completes, restart your terminal and verify the installation with:
rustc --version
cargo --version
If everything went smoothly, you’ll see output similar to:
rustc 1.80.0 (2025-07-15)
cargo 1.80.0 (2025-07-15)
These version numbers will differ depending on when you install Rust, but as long as you see valid versions, you’re good to proceed.
Why Cargo Matters
It’s worth pausing here to talk about cargo
, Rust’s package manager and build tool. Unlike ecosystems like JavaScript (with npm/yarn/pnpm) or Python (with pip/poetry), Cargo is tightly integrated with the language itself. You don’t need a separate tool for dependency management, building, testing, and running your app—Cargo does it all. For our todo app, Cargo will handle downloading and compiling third-party crates like Axum, Serde, and Tokio. It will also serve as our build orchestrator, ensuring reproducible builds across machines.
Creating a New Project
Now that Rust is installed, let’s scaffold a new project for our todo app. From your terminal, navigate to the directory where you keep your coding projects and run:
cargo new rust-axum-htmx-todo
cd rust-axum-htmx-todo
This creates a new folder with the name rust-axum-htmx-todo
and generates a very minimal project structure. Inside, you’ll find:
- Cargo.toml: This is the project manifest, where we’ll declare our dependencies and project metadata.
- src/main.rs: The default entry point of our application, containing a simple “Hello, world!” program.
Rust projects are intentionally minimal when generated, which is part of what makes them approachable.
Adding the Dependencies
Next, we’ll open the Cargo.toml
file and add the dependencies we’ll need for our app:
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
Let’s break down what each of these crates does:
- Axum: A powerful yet ergonomic web framework built on top of the
tower
ecosystem. Axum gives us routing, middleware, and request handling in a way that feels natural in Rust. - Tokio: Rust’s leading async runtime. Since HTTP servers are inherently asynchronous (they handle multiple requests concurrently), Tokio is the engine that allows Axum to scale efficiently.
- Serde: A battle-tested serialization and deserialization library. We’ll use it to parse form data when adding new tasks, and it’s also useful for JSON handling if you expand your app later.
By declaring these in Cargo.toml
, Cargo will automatically fetch and compile them the next time we build the project.
Setting Up HTMX
Now comes the front-end part of our stack. Unlike React or Vue, which require bundlers, transpilers, and often dozens of other dependencies, HTMX is refreshingly lightweight. To enable it, all you need to do is include a single <script>
tag in your HTML. Later, when our Axum server returns HTML pages, we’ll embed this line in the <head>
or just before the closing </body>
tag:
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
This one script unlocks the full power of HTMX. Attributes like hx-post
, hx-get
, and hx-target
will allow us to make asynchronous requests to our server and update parts of the page without refreshing the entire document. You’ll notice that HTMX feels more like “progressive enhancement” than a front-end framework—it works with your HTML instead of replacing it.
Why This Stack Works
At this stage, you may be wondering: why not just build a React SPA or use a Node.js backend with Express? The short answer is simplicity and control. With Rust + Axum, you get performance and memory safety out of the box. With HTMX, you add interactivity in a way that doesn’t burden you with complex build steps. Together, they create a developer experience that feels modern yet refreshingly lightweight.
For a todo app, this approach keeps us focused on actual functionality instead of fighting configuration. And the best part? The patterns we’ll learn here are scalable. The same techniques can be applied to dashboards, admin panels, and even production-grade SaaS apps.
Summary
To recap, we’ve installed Rust using rustup
, created a new Cargo project, declared our core dependencies, and prepared to integrate HTMX into our HTML. Everything is now in place to move forward. In the next part, we’ll write our first lines of Axum code and set up a simple web server. This server will serve as the foundation for our todo app, and from there, we’ll gradually introduce interactivity with HTMX.
Building the First Axum Server
With the environment set up, it’s time to write our first lines of Rust code and bring the server to life. At this stage, the goal isn’t to create a full todo app yet, but to make sure the Axum framework is wired correctly and serving responses. Think of this step as saying “hello” to the browser—it proves that everything works before we layer on interactivity with HTMX.
Writing a Simple Server
Open src/main.rs
and replace the default “Hello, world!” with the following:
use axum::{
routing::get,
Router,
};
use std::net::SocketAddr;
#[tokio::main]
async fn main() {
// define a router with a single route
let app = Router::new().route("/", get(root));
// set the address where the server will listen
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
println!("🚀 Server running on http://{}", addr);
// launch the server
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}
// handler for the root path
async fn root() -> &'static str {
"Hello from Axum + HTMX stack!"
}
Save the file and start the server with:
cargo run
Open your browser and visit http://localhost:3000. If everything is configured correctly, you’ll see the message:
Hello from Axum + HTMX stack!
Congratulations—you’ve just built your first Axum-powered server.
Breaking Down the Code
- Router
TheRouter::new()
object is where we define the routes for our app. For now, we only handle"/"
with a GET request, but later we’ll add routes for adding tasks, deleting tasks, and maybe even toggling task completion. - Handlers
Functions likeroot()
are called handlers. They define what happens when a request is received. In this case, our handler simply returns a static string, but we’ll soon return full HTML templates with embedded HTMX attributes. - Async Runtime
The#[tokio::main]
macro turns our main function into an asynchronous entry point. Axum relies on Tokio under the hood to handle thousands of requests concurrently without blocking. - Server Binding
SocketAddr::from(([127, 0, 0, 1], 3000))
tells the server to listen onlocalhost:3000
. You can change this port if needed, but 3000 is a common choice for development servers.
Why Start Small
It may feel anticlimactic to only return “Hello” at this point, but this step is essential. By confirming that your server compiles and runs, you eliminate a whole class of problems that could appear later. Once you’ve verified that Axum is working, you can confidently build on top of it.
In the next stage, we’ll swap out that simple text response for real HTML. We’ll build a basic page with a form and a list, then bring in HTMX to handle dynamic updates without full page reloads. That’s when our todo app will start to feel alive.
Building the Todo Page with HTMX
We’ll render a simple HTML page from Axum that contains:
- an input form to add tasks,
- a list container that HTMX can swap out after each submission.
Define a minimal data model and shared state
use axum::{
extract::{Form, State},
response::Html,
routing::{get, post},
Router,
};
use serde::Deserialize;
use std::{sync::{Arc, Mutex}, net::SocketAddr};
#[derive(Clone, Debug)]
struct AppState {
todos: Arc<Mutex<Vec<Todo>>>,
}
#[derive(Clone, Debug)]
struct Todo {
id: u64,
text: String,
done: bool,
}
#[derive(Deserialize)]
struct NewTodo {
text: String,
}
#[tokio::main]
async fn main() {
let state = AppState {
todos: Arc::new(Mutex::new(Vec::new())),
};
let app = Router::new()
.route("/", get(index))
.route("/add", post(add_todo))
.with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
println!("🚀 http://{addr}");
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}
Render the full page (with HTMX)
The page includes:
- the HTMX script,
- a form that posts to
/add
, - a
div
withid="todo-list"
that we’ll replace after each add.
async fn index(State(state): State<AppState>) -> Html<String> {
let list_html = render_list(&state);
Html(format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Rust Axum HTMX Todo App</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
body {{ font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 2rem; }}
h1 {{ margin-bottom: 1rem; }}
form {{ display: flex; gap: .5rem; margin-bottom: 1rem; }}
input[type=text] {{ flex: 1; padding: .6rem .8rem; border: 1px solid #ddd; border-radius: .5rem; }}
button {{ padding: .6rem 1rem; border: 0; border-radius: .5rem; background: #f97316; color: #fff; cursor: pointer; }}
ul {{ list-style: none; padding: 0; }}
li {{ display: flex; align-items: center; gap: .6rem; padding: .4rem 0; border-bottom: 1px dashed #eee; }}
.done {{ text-decoration: line-through; color: #888; }}
.muted {{ color: #666; font-size: .9rem; }}
</style>
</head>
<body>
<h1>Todos</h1>
<form hx-post="/add" hx-target="#todo-list" hx-swap="outerHTML">
<input type="text" name="text" placeholder="Add a task…" required autofocus>
<button type="submit">Add</button>
</form>
{list_html}
<p class="muted">Tip: HTMX replaces only the list container on submit—no full page reload.</p>
</body>
</html>"#
))
}
Return only the list fragment after POST
When HTMX submits the form, we won’t send the whole page back—just the list div
. Because hx-target="#todo-list"
and hx-swap="outerHTML"
are set, the returned fragment replaces the existing one.
async fn add_todo(
State(state): State<AppState>,
Form(new): Form<NewTodo>,
) -> Html<String> {
let mut todos = state.todos.lock().expect("poisoned");
let next_id = todos.last().map(|t| t.id + 1).unwrap_or(1);
todos.push(Todo { id: next_id, text: new.text, done: false });
drop(todos); // release the lock before rendering
Html(render_list(&state))
}
HTML renderer for the list
This helper builds a snippet that can be used both on the initial page render and for HTMX swaps.
fn render_list(state: &AppState) -> String {
let todos = state.todos.lock().expect("poisoned");
let items = if todos.is_empty() {
r#"<li class="muted">No tasks yet. Add your first one above.</li>"#.to_string()
} else {
todos.iter()
.map(|t| {
let cls = if t.done { "done" } else { "" };
format!(r#"<li><span class="{cls}">{}</span></li>"#, html_escape(&t.text))
})
.collect::<Vec<_>>()
.join("\n")
};
format!(r#"<div id="todo-list"><ul>{items}</ul></div>"#)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">")
}
Run and test
cargo run
# open http://localhost:3000
- Type a task and press Add.
- You should see the list update instantly without a full refresh.
- Open your browser devtools → Network tab to watch the
POST /add
response: it’s just the small HTML fragment.
Why this works so smoothly
- HTMX sends a form POST and expects HTML back.
- Axum handlers return
Html<String>
—no template engine required yet. - Because we swap only
#todo-list
, the rest of the page (including the input focus) stays intact.
If you want, we can extend this right away with:
- a delete button per item (
hx-delete
to/todos/{id}
), - a toggle done checkbox (
hx-patch
to/todos/{id}/toggle
) that re-renders only the changed row or the whole list, - SQLite persistence using
sqlx
so tasks survive restarts, - a tiny progress bar (completed/total) that updates via
hx-trigger="change from:body"
.
Tell me which upgrade you want first, and I’ll implement it in the same style.
Got it 👍 Let’s continue in English and make the toggle complete & delete features part of our Rust + Axum + HTMX todo app tutorial.
Adding Toggle and Delete Functionality
At this point, our todo app can add tasks and display them. To make it feel like a real application, we’ll add two important interactions:
- Toggling completion – a checkbox that marks a task as done or undone.
- Deleting tasks – a button that removes a task entirely.
Both of these will be powered by HTMX attributes (hx-patch
and hx-delete
) so they update the list without a full page reload.
Expanding the Routes
Update the router in main.rs
to handle the new endpoints:
let app = Router::new()
.route("/", get(index))
.route("/add", post(add_todo))
.route("/todos/:id/toggle", axum::routing::patch(toggle_todo))
.route("/todos/:id", axum::routing::delete(delete_todo))
.with_state(state);
Updating the List HTML
Now we’ll extend render_list
so each item has:
- a checkbox that triggers a
PATCH /todos/:id/toggle
request, - a delete button that triggers a
DELETE /todos/:id
request.
fn render_list(state: &AppState) -> String {
let todos = state.todos.lock().expect("poisoned");
let items = if todos.is_empty() {
r#"<li class="muted">No tasks yet. Add your first one above.</li>"#.to_string()
} else {
todos.iter()
.map(|t| {
let cls = if t.done { "done" } else { "" };
let checked = if t.done { "checked" } else { "" };
format!(
r#"<li>
<input type="checkbox" {checked}
hx-patch="/todos/{id}/toggle"
hx-target="#todo-list" hx-swap="outerHTML">
<span class="{cls}">{text}</span>
<button
hx-delete="/todos/{id}"
hx-target="#todo-list" hx-swap="outerHTML"
aria-label="Delete">✕</button>
</li>"#,
id = t.id,
text = html_escape(&t.text),
)
})
.collect::<Vec<_>>()
.join("\n")
};
format!(r#"<div id="todo-list"><ul>{items}</ul></div>"#)
}
You can enhance the CSS so checkboxes and delete buttons look neat:
li button { margin-left: auto; background:#ef4444; color:white; }
li input[type=checkbox] { transform: translateY(1px); }
Toggle Handler
The handler flips the done
state of the todo and re-renders the list fragment:
async fn toggle_todo(
State(state): State<AppState>,
Path(id): Path<u64>,
) -> Html<String> {
let mut todos = state.todos.lock().expect("poisoned");
if let Some(t) = todos.iter_mut().find(|t| t.id == id) {
t.done = !t.done;
}
drop(todos);
Html(render_list(&state))
}
Delete Handler
This removes the todo entirely:
async fn delete_todo(
State(state): State<AppState>,
Path(id): Path<u64>,
) -> Html<String> {
let mut todos = state.todos.lock().expect("poisoned");
todos.retain(|t| t.id != id);
drop(todos);
Html(render_list(&state))
}
Improving the Add Form
We can improve UX by clearing the input and keeping focus after submission. HTMX provides the hx-on
hook:
<form hx-post="/add" hx-target="#todo-list" hx-swap="outerHTML"
hx-on::after-request="this.reset(); this.querySelector('input[name=text]').focus();">
<input type="text" name="text" placeholder="Add a task…" required autofocus>
<button type="submit">Add</button>
</form>
Testing the App
- Run the app with
cargo run
. - Visit http://localhost:3000.
- Add a few tasks.
- Click the checkbox → the task should toggle with a strikethrough.
- Click the delete button → the task disappears instantly.
Why This Works
HTMX takes care of sending PATCH and DELETE requests to the server and only swaps the HTML fragment inside #todo-list
. That means the rest of the page (form, styles, scroll position) stays intact.
Adding Toggle and Delete Functionality
At this point, our todo app can add tasks and display them. To make it feel like a real application, we’ll add two important interactions:
- Toggling completion – a checkbox that marks a task as done or undone.
- Deleting tasks – a button that removes a task entirely.
Both of these will be powered by HTMX attributes (hx-patch
and hx-delete
) so they update the list without a full page reload.
Expanding the Routes
Update the router in main.rs
to handle the new endpoints:
let app = Router::new()
.route("/", get(index))
.route("/add", post(add_todo))
.route("/todos/:id/toggle", axum::routing::patch(toggle_todo))
.route("/todos/:id", axum::routing::delete(delete_todo))
.with_state(state);
Updating the List HTML
Now we’ll extend render_list
so each item has:
- a checkbox that triggers a
PATCH /todos/:id/toggle
request, - a delete button that triggers a
DELETE /todos/:id
request.
fn render_list(state: &AppState) -> String {
let todos = state.todos.lock().expect("poisoned");
let items = if todos.is_empty() {
r#"<li class="muted">No tasks yet. Add your first one above.</li>"#.to_string()
} else {
todos.iter()
.map(|t| {
let cls = if t.done { "done" } else { "" };
let checked = if t.done { "checked" } else { "" };
format!(
r#"<li>
<input type="checkbox" {checked}
hx-patch="/todos/{id}/toggle"
hx-target="#todo-list" hx-swap="outerHTML">
<span class="{cls}">{text}</span>
<button
hx-delete="/todos/{id}"
hx-target="#todo-list" hx-swap="outerHTML"
aria-label="Delete">✕</button>
</li>"#,
id = t.id,
text = html_escape(&t.text),
)
})
.collect::<Vec<_>>()
.join("\n")
};
format!(r#"<div id="todo-list"><ul>{items}</ul></div>"#)
}
You can enhance the CSS so checkboxes and delete buttons look neat:
li button { margin-left: auto; background:#ef4444; color:white; }
li input[type=checkbox] { transform: translateY(1px); }
Toggle Handler
The handler flips the done
state of the todo and re-renders the list fragment:
async fn toggle_todo(
State(state): State<AppState>,
Path(id): Path<u64>,
) -> Html<String> {
let mut todos = state.todos.lock().expect("poisoned");
if let Some(t) = todos.iter_mut().find(|t| t.id == id) {
t.done = !t.done;
}
drop(todos);
Html(render_list(&state))
}
Delete Handler
This removes the todo entirely:
async fn delete_todo(
State(state): State<AppState>,
Path(id): Path<u64>,
) -> Html<String> {
let mut todos = state.todos.lock().expect("poisoned");
todos.retain(|t| t.id != id);
drop(todos);
Html(render_list(&state))
}
Improving the Add Form
We can improve UX by clearing the input and keeping focus after submission. HTMX provides the hx-on
hook:
<form hx-post="/add" hx-target="#todo-list" hx-swap="outerHTML"
hx-on::after-request="this.reset(); this.querySelector('input[name=text]').focus();">
<input type="text" name="text" placeholder="Add a task…" required autofocus>
<button type="submit">Add</button>
</form>
Testing the App
- Run the app with
cargo run
. - Visit http://localhost:3000.
- Add a few tasks.
- Click the checkbox → the task should toggle with a strikethrough.
- Click the delete button → the task disappears instantly.
Why This Works
HTMX takes care of sending PATCH and DELETE requests to the server and only swaps the HTML fragment inside #todo-list
. That means the rest of the page (form, styles, scroll position) stays intact.
Adding SQLite persistence with sqlx
1) Dependencies
Open Cargo.toml
and add:
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
# NEW:
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "macros", "chrono"] }
dotenvy = "0.15"
chrono = { version = "0.4", features = ["clock"] }
sqlx
gives us async DB access + compile-time checked queries (when desired).dotenvy
loads.env
to configureDATABASE_URL
.chrono
for timestamps.
2) Database URL
Create a file named .env
in your project root:
DATABASE_URL=sqlite://todos.db
This creates/uses a SQLite file named todos.db
in your project directory.
3) (Optional but recommended) sqlx-cli
If you’d like to run migrations from the terminal:
cargo install sqlx-cli --no-default-features --features sqlite
Initialize the database and migrations folder:
# create the database file based on DATABASE_URL
sqlx database create
# create a migration
sqlx migrate add init
This creates a new file under migrations/<timestamp>_init.sql
. Open it and paste:
-- 0001_init.sql
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0, -- 0 = false, 1 = true
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Apply it:
sqlx migrate run
If you prefer not to install
sqlx-cli
, you can also embed/run migrations at runtime later. Using the CLI is simplest for beginners.
4) Application state: use a connection pool
Replace the old AppState
with a SqlitePool
. We’ll also update our model to match the DB schema.
use axum::{
extract::{Form, Path, State},
response::Html,
routing::{get, post},
Router,
};
use dotenvy::dotenv;
use serde::Deserialize;
use sqlx::{sqlite::SqlitePoolOptions, FromRow, SqlitePool};
use std::{env, net::SocketAddr};
#[derive(Clone)]
struct AppState {
pool: SqlitePool,
}
#[derive(FromRow, Debug, Clone)]
struct Todo {
id: i64,
text: String,
done: i64, // 0 or 1 in SQLite
// created_at can be added if you need it in UI:
// created_at: chrono::NaiveDateTime,
}
#[derive(Deserialize)]
struct NewTodo {
text: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv().ok(); // load .env if present
let db_url = env::var("DATABASE_URL")
.expect("DATABASE_URL must be set, e.g. sqlite://todos.db");
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect(&db_url)
.await?;
// If you don’t use sqlx-cli, you could ensure the table exists here:
// sqlx::query(r#"CREATE TABLE IF NOT EXISTS todos (...) "#).execute(&pool).await?;
let state = AppState { pool };
let app = Router::new()
.route("/", get(index))
.route("/add", post(add_todo))
.route("/todos/:id/toggle", axum::routing::patch(toggle_todo))
.route("/todos/:id", axum::routing::delete(delete_todo))
.with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
println!("🚀 http://{addr}");
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await?;
Ok(())
}
5) Query helpers
Add small helpers to list, insert, toggle, and delete todos. You can place these below main
in the same file for now.
async fn list_todos(pool: &SqlitePool) -> Result<Vec<Todo>, sqlx::Error> {
sqlx::query_as::<_, Todo>(
r#"SELECT id, text, done FROM todos ORDER BY id DESC"#
)
.fetch_all(pool)
.await
}
async fn insert_todo(pool: &SqlitePool, text: &str) -> Result<(), sqlx::Error> {
sqlx::query(
r#"INSERT INTO todos (text, done) VALUES (?1, 0)"#
)
.bind(text)
.execute(pool)
.await?;
Ok(())
}
async fn toggle_todo_db(pool: &SqlitePool, id: i64) -> Result<(), sqlx::Error> {
// flip 0/1
sqlx::query(
r#"UPDATE todos SET done = CASE done WHEN 0 THEN 1 ELSE 0 END WHERE id = ?1"#
)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
async fn delete_todo_db(pool: &SqlitePool, id: i64) -> Result<(), sqlx::Error> {
sqlx::query(
r#"DELETE FROM todos WHERE id = ?1"#
)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
6) Handlers now use the database
Update handlers to call those helpers and re-render the HTML fragment.
async fn index(State(state): State<AppState>) -> Html<String> {
let list_html = render_list(&state).await.unwrap_or_else(|e| {
format!(r#"<div id="todo-list"><ul>
<li class="muted">Error loading todos: {}</li>
</ul></div>"#, html_escape(&e.to_string()))
});
Html(format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Rust Axum HTMX Todo App</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
body {{ font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 2rem; }}
h1 {{ margin-bottom: 1rem; }}
form {{ display: flex; gap: .5rem; margin-bottom: 1rem; }}
input[type=text] {{ flex: 1; padding: .6rem .8rem; border: 1px solid #ddd; border-radius: .5rem; }}
button {{ padding: .6rem 1rem; border: 0; border-radius: .5rem; background: #f97316; color: #fff; cursor: pointer; }}
ul {{ list-style: none; padding: 0; }}
li {{ display: flex; align-items: center; gap: .6rem; padding: .4rem 0; border-bottom: 1px dashed #eee; }}
.done {{ text-decoration: line-through; color: #888; }}
.muted {{ color: #666; font-size: .9rem; }}
li button {{ margin-left: auto; background:#ef4444; color:#fff; border-radius:.4rem; padding:.3rem .6rem; }}
li input[type=checkbox] {{ transform: translateY(1px); }}
</style>
</head>
<body>
<h1>Todos</h1>
<form hx-post="/add" hx-target="#todo-list" hx-swap="outerHTML"
hx-on::after-request="this.reset(); this.querySelector('input[name=text]').focus();">
<input type="text" name="text" placeholder="Add a task…" required autofocus>
<button type="submit">Add</button>
</form>
{list_html}
<p class="muted">Data is persisted with SQLite using sqlx.</p>
</body>
</html>"#
))
}
async fn add_todo(
State(state): State<AppState>,
Form(new): Form<NewTodo>,
) -> Html<String> {
let _ = insert_todo(&state.pool, &new.text).await;
Html(render_list(&state).await.unwrap_or_else(error_list))
}
async fn toggle_todo(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Html<String> {
let _ = toggle_todo_db(&state.pool, id).await;
Html(render_list(&state).await.unwrap_or_else(error_list))
}
async fn delete_todo(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Html<String> {
let _ = delete_todo_db(&state.pool, id).await;
Html(render_list(&state).await.unwrap_or_else(error_list))
}
7) Rendering the list from the database
We’ll fetch rows, build <li>
items, and return the same fragment we used earlier so HTMX can swap it.
async fn render_list(state: &AppState) -> Result<String, sqlx::Error> {
let todos = list_todos(&state.pool).await?;
let items = if todos.is_empty() {
r#"<li class="muted">No tasks yet. Add your first one above.</li>"#.to_string()
} else {
todos
.iter()
.map(|t| {
let cls = if t.done == 1 { "done" } else { "" };
let checked = if t.done == 1 { "checked" } else { "" };
format!(
r#"<li>
<input type="checkbox" {checked}
hx-patch="/todos/{id}/toggle"
hx-target="#todo-list" hx-swap="outerHTML">
<span class="{cls}">{text}</span>
<button
hx-delete="/todos/{id}"
hx-target="#todo-list" hx-swap="outerHTML"
aria-label="Delete">✕</button>
</li>"#,
id = t.id,
text = html_escape(&t.text),
)
})
.collect::<Vec<_>>()
.join("\n")
};
Ok(format!(r#"<div id="todo-list"><ul>{items}</ul></div>"#))
}
fn error_list<E: std::fmt::Display>(e: E) -> String {
format!(r#"<div id="todo-list"><ul>
<li class="muted">Error: {}</li>
</ul></div>"#, html_escape(&e.to_string()))
}
fn html_escape(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">")
}
8) Run it
If you used the CLI:
sqlx database create # if not already created
sqlx migrate run
cargo run
# open http://localhost:3000
Type a few tasks, toggle them, delete some, then stop and restart the server — your tasks will still be there.
9) Where to go next
- Add a progress bar (completed/total) that updates via HTMX after any action.
- Add server-side validation and show inline error messages (return a tiny
<div class="error">…</div>
fragment to swap near the form). - Introduce a template engine (Askama, Maud, or Minijinja) to avoid hand-built strings.
- Support filters: “All / Active / Completed” using
hx-get
links that swap just the list.
If you want, I can add the progress bar and a tiny filter bar next, still in this minimalist Axum + HTMX style.
Progress bar + filters
We’ll:
- compute counts server-side,
- render a small progress widget that HTMX re-renders after any change,
- add three filter links that re-query the DB and return a list fragment.
1) Database helpers for counts and filtered queries
Add these helpers (below your other DB helpers):
#[derive(Copy, Clone, Debug)]
enum Filter {
All,
Active,
Completed,
}
impl Filter {
fn from_str(s: &str) -> Self {
match s {
"active" => Filter::Active,
"completed" => Filter::Completed,
_ => Filter::All,
}
}
fn as_str(&self) -> &'static str {
match self {
Filter::All => "all",
Filter::Active => "active",
Filter::Completed => "completed",
}
}
}
async fn list_todos_filtered(pool: &SqlitePool, f: Filter) -> Result<Vec<Todo>, sqlx::Error> {
match f {
Filter::All => {
sqlx::query_as::<_, Todo>(r#"SELECT id, text, done FROM todos ORDER BY id DESC"#)
.fetch_all(pool).await
}
Filter::Active => {
sqlx::query_as::<_, Todo>(r#"SELECT id, text, done FROM todos WHERE done = 0 ORDER BY id DESC"#)
.fetch_all(pool).await
}
Filter::Completed => {
sqlx::query_as::<_, Todo>(r#"SELECT id, text, done FROM todos WHERE done = 1 ORDER BY id DESC"#)
.fetch_all(pool).await
}
}
}
async fn counts(pool: &SqlitePool) -> Result<(i64, i64), sqlx::Error> {
// returns (total, completed)
let (total,): (i64,) = sqlx::query_as(r#"SELECT COUNT(*) FROM todos"#)
.fetch_one(pool).await?;
let (completed,): (i64,) = sqlx::query_as(r#"SELECT COUNT(*) FROM todos WHERE done = 1"#)
.fetch_one(pool).await?;
Ok((total, completed))
}
2) Routes for filter + progress fragments
Extend your router:
let app = Router::new()
.route("/", get(index))
.route("/add", post(add_todo))
.route("/todos/:id/toggle", axum::routing::patch(toggle_todo))
.route("/todos/:id", axum::routing::delete(delete_todo))
// NEW:
.route("/list", get(list_fragment)) // ?filter=all|active|completed
.route("/progress", get(progress_fragment)) // small widget
.with_state(state);
3) The main page renders filter bar + progress widget
Update index
to include:
- a filter bar (
#filter-bar
) with three links usinghx-get="/list?filter=..."
, - a progress bar container (
#progress
) that we can refresh after any change, - a hidden trigger that refreshes progress after list updates (nice HTMX trick).
async fn index(State(state): State<AppState>) -> Html<String> {
let current = Filter::All;
let list_html = render_list_filtered(&state, current).await.unwrap_or_else(error_list);
let progress_html = render_progress(&state).await.unwrap_or_else(|e| {
format!(r#"<div id="progress" class="muted">Error: {}</div>"#, html_escape(&e.to_string()))
});
Html(format!(r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Rust Axum HTMX Todo App</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
body {{ font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 2rem; }}
h1 {{ margin-bottom: .25rem; }}
.row {{ display:flex; align-items:center; gap:1rem; flex-wrap:wrap; }}
.filters a {{ padding:.3rem .6rem; border-radius:.4rem; text-decoration:none; border:1px solid #ddd; color:#333; }}
.filters a.active {{ background:#f97316; color:#fff; border-color:#f97316; }}
.progress-wrap {{ display:flex; align-items:center; gap:.6rem; }}
.bar {{ height:8px; width:220px; background:#eee; border-radius:999px; overflow:hidden; }}
.bar > span {{ display:block; height:8px; background:#16a34a; }}
form {{ display:flex; gap:.5rem; margin:1rem 0; }}
input[type=text] {{ flex:1; padding:.6rem .8rem; border:1px solid #ddd; border-radius:.5rem; }}
button {{ padding:.6rem 1rem; border:0; border-radius:.5rem; background:#f97316; color:#fff; cursor:pointer; }}
ul {{ list-style:none; padding:0; }}
li {{ display:flex; align-items:center; gap:.6rem; padding:.4rem 0; border-bottom:1px dashed #eee; }}
.done {{ text-decoration:line-through; color:#888; }}
.muted {{ color:#666; font-size:.9rem; }}
li button {{ margin-left:auto; background:#ef4444; color:#fff; border-radius:.4rem; padding:.3rem .6rem; }}
li input[type=checkbox] {{ transform: translateY(1px); }}
</style>
</head>
<body>
<h1>Todos</h1>
<div class="row">
<nav id="filter-bar" class="filters">
{filter_links("all")}
</nav>
{progress_html}
</div>
<form hx-post="/add"
hx-target="#todo-list" hx-swap="outerHTML"
hx-on::after-request="this.reset(); this.querySelector('input[name=text]').focus();
htmx.trigger('#progress','refresh');">
<input type="text" name="text" placeholder="Add a task…" required autofocus>
<button type="submit">Add</button>
</form>
{list_html}
<!-- hidden trigger target to refresh progress after list swaps -->
<div id="progress"
hx-get="/progress"
hx-trigger="refresh from:body"
hx-swap="outerHTML"></div>
<p class="muted">Use the filter to view All, Active, or Completed. The progress updates automatically.</p>
</body>
</html>"#))
}
Helper to render the three links with the active state:
fn filter_links(active: &str) -> String {
let link = |name: &str, label: &str| -> String {
let cls = if active == name { "active" } else { "" };
format!(
r#"<a class="{cls}" hx-get="/list?filter={name}" hx-target="#todo-list" hx-swap="outerHTML"
hx-on::after-request="htmx.trigger('#progress','refresh');">{label}</a>"#,
)
};
format!(
r#"{} {} {}"#,
link("all", "All"),
link("active", "Active"),
link("completed", "Completed"),
)
}
Note: On first load we pass
"all"
so “All” appears selected. If you want the active state to update when switching filters, you can re-render the filter bar too (optional; shown at the end).
4) The list and progress endpoints
These return fragments so HTMX can swap them.
use axum::extract::Query;
use std::collections::HashMap;
async fn list_fragment(
State(state): State<AppState>,
Query(q): Query<HashMap<String, String>>,
) -> Html<String> {
let f = Filter::from_str(q.get("filter").map(String::as_str).unwrap_or("all"));
Html(render_list_filtered(&state, f).await.unwrap_or_else(error_list))
}
async fn progress_fragment(
State(state): State<AppState>,
) -> Html<String> {
Html(render_progress(&state).await.unwrap_or_else(|e| {
Html(format!(r#"<div id="progress" class="muted">Error: {}</div>"#, html_escape(&e.to_string()))).0
}))
}
5) Rendering the list with a filter
async fn render_list_filtered(state: &AppState, f: Filter) -> Result<String, sqlx::Error> {
let todos = list_todos_filtered(&state.pool, f).await?;
let items = if todos.is_empty() {
r#"<li class="muted">No tasks found for this view.</li>"#.to_string()
} else {
todos.iter()
.map(|t| {
let cls = if t.done == 1 { "done" } else { "" };
let checked = if t.done == 1 { "checked" } else { "" };
format!(
r#"<li>
<input type="checkbox" {checked}
hx-patch="/todos/{id}/toggle"
hx-target="#todo-list" hx-swap="outerHTML"
hx-on::after-request="htmx.trigger('#progress','refresh');">
<span class="{cls}">{text}</span>
<button
hx-delete="/todos/{id}"
hx-target="#todo-list" hx-swap="outerHTML"
hx-on::after-request="htmx.trigger('#progress','refresh');"
aria-label="Delete">✕</button>
</li>"#,
id = t.id,
text = html_escape(&t.text),
)
})
.collect::<Vec<_>>()
.join("\n")
};
Ok(format!(r#"<div id="todo-list" data-filter="{}"><ul>{items}</ul></div>"#, f.as_str()))
}
6) Rendering the progress widget
async fn render_progress(state: &AppState) -> Result<String, sqlx::Error> {
let (total, completed) = counts(&state.pool).await?;
let pct = if total == 0 { 0.0 } else { (completed as f64 / total as f64) * 100.0 };
Ok(format!(
r#"<div id="progress" class="progress-wrap">
<div class="bar"><span style="width:{:.0}%"></span></div>
<div class="muted">{}/{} completed ({:.0}%)</div>
</div>"#,
pct, completed, total, pct
))
}
7) Make actions refresh progress automatically
We already added hx-on::after-request="htmx.trigger('#progress','refresh');"
to:
- the form submit,
- the checkbox toggle,
- the delete button.
That fires a custom event refresh
targeting #progress
, and our hidden #progress
element reloads itself with hx-get="/progress"
.
8) (Optional) Re-render the filter bar’s active state
If you want the active pill to change visually when users switch filters, return a pair of fragments and swap both bars. The simplest approach is to give the filter bar its own endpoint and target:
- Add a route:
.route("/filters", get(filters_fragment))
- Implement:
async fn filters_fragment(
Query(q): Query<HashMap<String, String>>,
) -> Html<String> {
let f = q.get("filter").map(String::as_str).unwrap_or("all");
Html(format!(r#"<nav id="filter-bar" class="filters">{}</nav>"#, filter_links(f)))
}
- Then change each filter link to also refresh
#filter-bar
:
<a class="{cls}"
hx-get="/list?filter={name}" hx-target="#todo-list" hx-swap="outerHTML"
hx-on::after-request="htmx.ajax('GET','/filters?filter={name}', {target:'#filter-bar', swap:'outerHTML'});
htmx.trigger('#progress','refresh');">{label}</a>
This keeps the pills perfectly in sync with the current view.
That’s it! You now have:
- persistent todos (SQLite),
- instant UI updates (HTMX),
- useful UX polish (filters + progress).
Refactor: Move HTML into Askama Templates
So far, all of our HTML has been constructed as strings inside Rust functions. While this works for a prototype, it quickly becomes messy and hard to maintain. A better approach is to separate presentation from logic using a template engine. In Rust, one of the most popular options is Askama, which uses a Jinja2-like syntax and integrates seamlessly with Axum.
By moving our HTML into template files, we’ll gain:
- Maintainability – easier to edit markup and styles.
- Reusability – base layouts and shared components.
- Safety – automatic HTML escaping to prevent XSS.
- Cleaner Rust code – focus on data, not string concatenation.
1. Add dependencies
Open Cargo.toml
and include:
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "macros", "chrono"] }
dotenvy = "0.15"
chrono = { version = "0.4", features = ["clock"] }
askama = "0.12"
askama_axum = "0.4"
askama
gives us the templating system.askama_axum
lets us return templates directly as Axum responses.
Create a new templates/
directory at the root of your project.
2. Base layout
templates/base.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}Rust Axum HTMX Todo App{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 2rem; }
h1 { margin-bottom: .25rem; }
.row { display:flex; align-items:center; gap:1rem; flex-wrap:wrap; }
.filters a { padding:.3rem .6rem; border-radius:.4rem; text-decoration:none; border:1px solid #ddd; color:#333; }
.filters a.active { background:#f97316; color:#fff; border-color:#f97316; }
.progress-wrap { display:flex; align-items:center; gap:.6rem; }
.bar { height:8px; width:220px; background:#eee; border-radius:999px; overflow:hidden; }
.bar > span { display:block; height:8px; background:#16a34a; }
form { display:flex; gap:.5rem; margin:1rem 0; }
input[type=text] { flex:1; padding:.6rem .8rem; border:1px solid #ddd; border-radius:.5rem; }
button { padding:.6rem 1rem; border:0; border-radius:.5rem; background:#f97316; color:#fff; cursor:pointer; }
ul { list-style:none; padding:0; }
li { display:flex; align-items:center; gap:.6rem; padding:.4rem 0; border-bottom:1px dashed #eee; }
.done { text-decoration:line-through; color:#888; }
.muted { color:#666; font-size:.9rem; }
li button { margin-left:auto; background:#ef4444; color:#fff; border-radius:.4rem; padding:.3rem .6rem; }
li input[type=checkbox] { transform: translateY(1px); }
</style>
{% block head %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
3. Main page template
templates/index.html
{% extends "base.html" %}
{% block title %}Rust Axum HTMX Todo App{% endblock %}
{% block body %}
<h1>Todos</h1>
<div class="row">
<nav id="filter-bar" class="filters">
{{ filter_bar|safe }}
</nav>
{{ progress|safe }}
</div>
<form hx-post="/add"
hx-target="#todo-list" hx-swap="outerHTML"
hx-on::after-request="this.reset(); this.querySelector('input[name=text]').focus();
htmx.trigger('#progress','refresh');">
<input type="text" name="text" placeholder="Add a task…" required autofocus>
<button type="submit">Add</button>
</form>
{{ list_html|safe }}
<div id="progress"
hx-get="/progress"
hx-trigger="refresh from:body"
hx-swap="outerHTML"></div>
<p class="muted">Use the filter to view All, Active, or Completed. The progress updates automatically.</p>
{% endblock %}
4. List fragment
templates/list.html
<div id="todo-list" data-filter="{{ active_filter }}">
<ul>
{% if todos|length == 0 %}
<li class="muted">No tasks found for this view.</li>
{% else %}
{% for t in todos %}
<li>
<input type="checkbox" {% if t.done %}checked{% endif %}
hx-patch="/todos/{{ t.id }}/toggle"
hx-target="#todo-list" hx-swap="outerHTML"
hx-on::after-request="htmx.trigger('#progress','refresh');">
<span class="{% if t.done %}done{% endif %}">{{ t.text | e }}</span>
<button
hx-delete="/todos/{{ t.id }}"
hx-target="#todo-list" hx-swap="outerHTML"
hx-on::after-request="htmx.trigger('#progress','refresh');"
aria-label="Delete">✕</button>
</li>
{% endfor %}
{% endif %}
</ul>
</div>
5. Progress widget
templates/progress.html
<div id="progress" class="progress-wrap">
<div class="bar"><span style="width:{{ percent }}%"></span></div>
<div class="muted">{{ completed }}/{{ total }} completed ({{ percent }}%)</div>
</div>
6. Filters fragment
templates/filters.html
<nav id="filter-bar" class="filters">
<a class="{% if active == "all" %}active{% endif %}"
hx-get="/list?filter=all" hx-target="#todo-list" hx-swap="outerHTML"
hx-on::after-request="htmx.ajax('GET','/filters?filter=all',{target:'#filter-bar',swap:'outerHTML'}); htmx.trigger('#progress','refresh');">
All
</a>
<a class="{% if active == "active" %}active{% endif %}"
hx-get="/list?filter=active" hx-target="#todo-list" hx-swap="outerHTML"
hx-on::after-request="htmx.ajax('GET','/filters?filter=active',{target:'#filter-bar',swap:'outerHTML'}); htmx.trigger('#progress','refresh');">
Active
</a>
<a class="{% if active == "completed" %}active{% endif %}"
hx-get="/list?filter=completed" hx-target="#todo-list" hx-swap="outerHTML"
hx-on::after-request="htmx.ajax('GET','/filters?filter=completed',{target:'#filter-bar',swap:'outerHTML'}); htmx.trigger('#progress','refresh');">
Completed
</a>
</nav>
7. Rust template structs
In your Rust code, define structs that map to these templates:
use askama::Template;
use askama_axum::IntoResponse;
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate<'a> {
filter_bar: &'a str,
progress: &'a str,
list_html: &'a str,
}
#[derive(Template)]
#[template(path = "list.html")]
struct ListTemplate<'a> {
active_filter: &'a str,
todos: &'a [ViewTodo],
}
#[derive(Template)]
#[template(path = "progress.html")]
struct ProgressTemplate {
total: i64,
completed: i64,
percent: i64,
}
#[derive(Template)]
#[template(path = "filters.html")]
struct FiltersTemplate<'a> {
active: &'a str,
}
#[derive(Clone)]
struct ViewTodo {
id: i64,
text: String,
done: bool,
}
8. Handlers now render templates
Replace the string-based handlers with template rendering:
async fn index(State(state): State<AppState>) -> impl IntoResponse {
let filter_bar = FiltersTemplate { active: "all" }.render().unwrap_or_default();
let progress = render_progress_html(&state).await.unwrap_or_default();
let list_html = render_list_html(&state, Filter::All).await.unwrap_or_default();
IndexTemplate {
filter_bar: &filter_bar,
progress: &progress,
list_html: &list_html,
}
}
Helper functions for rendering list and progress:
async fn render_list_html(state: &AppState, f: Filter) -> Result<String, sqlx::Error> {
let rows = list_todos_filtered(&state.pool, f).await?;
let todos: Vec<ViewTodo> = rows.into_iter().map(|t| ViewTodo {
id: t.id,
text: t.text,
done: t.done == 1,
}).collect();
Ok(ListTemplate {
active_filter: f.as_str(),
todos: &todos,
}.render().unwrap_or_default())
}
async fn render_progress_html(state: &AppState) -> Result<String, sqlx::Error> {
let (total, completed) = counts(&state.pool).await?;
let pct = if total == 0 { 0 } else { ((completed as f64 / total as f64) * 100.0).round() as i64 };
Ok(ProgressTemplate { total, completed, percent: pct }
.render()
.unwrap_or_default())
}
Why this is better
- All markup lives in
templates/
, not scattered across strings. - Askama ensures type safety and escaping.
- We can now expand the UI more easily (new buttons, styles, or components).
What we’ll build
- A small validator for the task text (non-empty, length limit, no control chars).
- A persistent error container right under the form:
<div id="form-errors">…</div>
. - When
/add
fails, the server returns a tiny HTML fragment withhx-swap-oob="true"
that replaces only#form-errors
. - On success, the server returns:
- the updated list fragment (normal swap to
#todo-list
), and - an empty error box via OOB to clear previous messages.
- the updated list fragment (normal swap to
1) Minimal UI hook for errors (template change)
Add a dedicated error container to your templates/index.html
form area (right under the form). Also add a bit of CSS in your base to make it visible.
templates/base.html – add a simple style:
<style>
/* …existing styles… */
.error { color:#b91c1c; background:#fee2e2; border:1px solid #fecaca; padding:.5rem .75rem; border-radius:.5rem; }
</style>
templates/index.html – right below the <form>
:
<form hx-post="/add"
hx-target="#todo-list" hx-swap="outerHTML"
hx-on::after-request="if (event.detail.xhr.status === 200) { this.reset(); this.querySelector('input[name=text]').focus(); }
htmx.trigger('#progress','refresh');">
<input type="text" name="text" placeholder="Add a task…" required autofocus>
<button type="submit">Add</button>
</form>
<!-- error box gets replaced out-of-band -->
<div id="form-errors"></div>
{{ list_html|safe }}
We kept the existing
hx-target="#todo-list"
so success still refreshes the list. The error box is updated OOB.
2) Error fragment templates
Create a tiny template for rendering errors and another for clearing them.
templates/errors.html
<div id="form-errors" class="error" hx-swap-oob="true">
{{ message | e }}
</div>
templates/errors_clear.html
<div id="form-errors" hx-swap-oob="true"></div>
The
hx-swap-oob="true"
flag tells HTMX: “Replace#form-errors
in the current page, even if the actual target of the request is something else.”
3) Rust types for those fragments
use askama::Template;
use askama_axum::IntoResponse;
#[derive(Template)]
#[template(path = "errors.html")]
struct ErrorsTemplate<'a> {
message: &'a str,
}
#[derive(Template)]
#[template(path = "errors_clear.html")]
struct ErrorsClearTemplate;
4) Server-side validation
Add a small validator. Keep it pragmatic: trim whitespace, enforce bounds, and ban control characters.
fn validate_text(raw: &str) -> Result<String, String> {
let text = raw.trim();
if text.is_empty() {
return Err("Please enter a task.".into());
}
// basic control-char guard (you can make this stricter if needed)
if text.chars().any(|c| c.is_control() && !c.is_ascii_whitespace()) {
return Err("Your task contains invalid characters.".into());
}
// byte-length cap; adjust as you like (consider grapheme clusters if needed)
if text.len() > 200 {
return Err("Keep it short—200 characters max.".into());
}
Ok(text.to_string())
}
5) Update the /add
handler
- On error: return 422 plus only the errors fragment (OOB). We also return a harmless no-op element so the normal target swap doesn’t visually disturb the list.
- On success: insert the todo, return the updated list (normal swap) and an empty errors box via OOB to clear any previous errors.
use axum::{http::StatusCode, response::{Html, IntoResponse}};
async fn add_todo(
State(state): State<AppState>,
Form(new): Form<NewTodo>,
) -> impl IntoResponse {
match validate_text(&new.text) {
Err(msg) => {
// Render error box (OOB) and return 422
let err_html = ErrorsTemplate { message: &msg }.render().unwrap_or_default();
// return a tiny body: OOB error + no-op for the normal target
let body = format!(r#"{err_html}<!-- noop -->"#);
(StatusCode::UNPROCESSABLE_ENTITY, Html(body))
}
Ok(clean) => {
// Insert and return updated list + clear the errors (OOB)
let _ = insert_todo(&state.pool, &clean).await;
let list_html = render_list_html(&state, Filter::All).await.unwrap_or_default();
let clear_html = ErrorsClearTemplate.render().unwrap_or_default();
let body = format!("{clear_html}{list_html}");
(StatusCode::OK, Html(body))
}
}
}
Why this works seamlessly
- On success, the first fragment in the response is the OOB
<div id="form-errors">…</div>
, which clears the box. - The second fragment is the list HTML, which HTMX swaps into
#todo-list
as usual. - On failure, we return only the OOB error and a no-op; the list stays untouched.
6) Optional: per-field highlighting
If you’d like to highlight the input when there’s an error, you can send a tiny OOB script to add a CSS class, or return a class change via HX-Retarget/HX-Reswap headers. The OOB approach is simpler:
- Add a subtle red border style in
base.html
:<style> /* … */ .invalid { border-color:#ef4444 !important; box-shadow:0 0 0 3px #fee2e2; } </style>
- Append a tiny OOB script when invalid:
let err_html = format!( r#" {} <script hx-swap-oob="true"> document.querySelector('input[name=text]')?.classList.add('invalid'); </script> "#, ErrorsTemplate { message: &msg }.render().unwrap_or_default() );
- And on success, clear it:
let clear_html = format!( r#" {} <script hx-swap-oob="true"> document.querySelector('input[name=text]')?.classList.remove('invalid'); </script> "#, ErrorsClearTemplate.render().unwrap_or_default() );
7) Optional: de-duplication or rate-limit
Two easy guards you can add to the validator flow:
- Duplicate suppressor (case-insensitive exact matches):
async fn exists_same_text(pool: &SqlitePool, text: &str) -> Result<bool, sqlx::Error> { let (count,): (i64,) = sqlx::query_as( r#"SELECT COUNT(*) FROM todos WHERE lower(text) = lower(?1)"# ).bind(text).fetch_one(pool).await?; Ok(count > 0) }
Inadd_todo
, afterOk(clean)
, early-return 422 ifexists_same_text(&state.pool, &clean).await == Ok(true)
. - Simple rate-limit (e.g., at most 1 add per 500ms per client): store a timestamp in session/cookie or in-memory map keyed by
client_ip
.
8) Quick test checklist
- Submit an empty task → the form doesn’t reload; an error box appears; the list remains as-is; HTTP status is 422.
- Submit a very long task (>200 bytes) → same behavior as above with a length message.
- Submit a valid task → list updates; input clears and gets focus; error box disappears.
- Toggle/delete still trigger the progress refresh unchanged.
CSRF Protection (Cross-Site Request Forgery)
HTMX simplifies form submissions and AJAX requests, but it does not automatically protect against CSRF. The server must handle this. A common approach in web applications is the Double Submit Cookie pattern.
1. The idea
- The server generates a random CSRF token.
- The token is stored both in a cookie and as a hidden form input.
- When the client submits a form, both values are sent.
- The server checks that the cookie and form value match.
2. Middleware in Axum
We can implement CSRF protection as middleware. For example:
use axum::{
middleware::Next,
response::Response,
http::{Request, StatusCode, Method},
};
use rand::RngCore;
use base64::Engine;
async fn csrf_protect<B>(req: Request<B>, next: Next<B>) -> Result<Response, StatusCode> {
if matches!(req.method(), &Method::POST | &Method::PATCH | &Method::DELETE) {
let cookie_header = req.headers().get(axum::http::header::COOKIE);
let token_in_cookie = cookie_header
.and_then(|v| v.to_str().ok())
.and_then(|s| {
s.split(';').find_map(|c| {
let parts: Vec<&str> = c.trim().split('=').collect();
if parts.len() == 2 && parts[0] == "csrf" {
Some(parts[1].to_string())
} else {
None
}
})
});
let token_in_header = req.headers().get("X-CSRF-Token").and_then(|v| v.to_str().ok());
if token_in_cookie.is_none() || token_in_header.is_none() {
return Err(StatusCode::UNAUTHORIZED);
}
if token_in_cookie != token_in_header.map(|s| s.to_string()) {
return Err(StatusCode::FORBIDDEN);
}
}
Ok(next.run(req).await)
}
fn generate_csrf_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
3. Adding the token to forms
When rendering templates, embed the CSRF token:
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
Also set it in a cookie when the page loads. HTMX will automatically include the hidden field in form submissions.
Integration Tests with Axum
One of the strengths of Axum is that your app implements the tower::Service
trait. That means you can run full request/response cycles in tests without starting a real server.
1. Dependencies
In Cargo.toml
, add test-only dependencies:
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
hyper = "0.14"
tower = "0.4"
2. Example test file
Create tests/integration_test.rs
:
use tower::ServiceExt; // for `oneshot`
use axum::{body::Body, http::{Request, StatusCode}};
use rust_axum_htmx_todo::app; // assume `app()` builds the Router
#[tokio::test]
async fn test_index_page_loads() {
let app = app().await;
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_add_todo_validation() {
let app = app().await;
let req = Request::builder()
.uri("/add")
.method("POST")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("text=")) // invalid input: empty text
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
3. Best practices
- Use an in-memory SQLite DB (
sqlite::memory:
) during tests so state resets on each run. - Before each test, run
sqlx::migrate!().run(&pool).await.unwrap();
to apply migrations. - Test
/add
,/toggle
,/delete
routes by checking not just status codes, but also returned HTML fragments.
✅ With these steps:
- You now protect forms against CSRF attacks.
- You can confidently verify your routes and validation logic with integration tests.
- The app is safer and more production-ready.
Conclusion: Why Rust + Axum + HTMX Works So Well
Over the course of this tutorial, we built a complete, production-style Rust Axum HTMX todo app from the ground up. What started as a simple “Hello, world” server evolved into a feature-rich application with:
- Task management features: add, toggle, and delete.
- Persistence: using SQLite +
sqlx
to store tasks permanently. - Dynamic UI: powered by HTMX attributes, enabling interactivity without heavy front-end frameworks.
- UX polish: progress bar and filter options for All, Active, and Completed tasks.
- Clean templates: with Askama, separating presentation from business logic.
- Validation & error handling: inline, user-friendly feedback for invalid submissions.
- Security & reliability: CSRF protection middleware and integration tests to ensure safe, correct behavior.
This stack demonstrates a powerful idea:
👉 You don’t need a massive JavaScript front-end framework to build responsive, interactive apps. Instead, you can keep the client lightweight, render HTML on the server, and use HTMX for just-in-time interactivity.
With Rust, you also gain:
- performance that rivals low-level languages,
- safety guarantees against memory errors,
- async scalability for thousands of concurrent requests,
- a growing ecosystem of production-ready libraries.
And with Axum, you get ergonomic routing and integration with the wider Tower ecosystem. Combined with HTMX, you deliver a modern UX without the complexity of SPAs.
Where to Go From Here
If you enjoyed this project, here are some natural next steps:
- Authentication & users: add signup/login and user-specific task lists.
- Real-time updates: integrate SSE or WebSockets so multiple clients stay in sync.
- Deployment: package the app with Docker, run migrations in CI/CD, and host on a VPS or cloud provider.
- Scaling up: migrate from SQLite to Postgres for multi-user production workloads.
- UI enhancement: add animations, accessibility improvements, or even pair HTMX with Alpine.js for richer client-side logic.
✅ By completing this tutorial, you’ve not only built a todo app—you’ve explored a development philosophy: lean, secure, and maintainable web apps using Rust on the backend and minimal JavaScript on the frontend. This approach is increasingly appealing to teams who want performance, security, and simplicity without drowning in complexity.