Earlier in my career I was tasked with building an internal tool that captured form input from users. Easy enough: for anyone who has worked on the web, a form is probably one of the first tutorials you build. The trick came when the users wanted to drive the page's configuration dynamically, with forms covering hundreds of different fields (drop-downs with dynamic data, radio buttons, text areas, date pickers and so on). Looking back, I suspect the product owner did not fully know what data they wanted to capture or why. They wanted something that could just render a form, so they could capture new and different data points fast and feed them into structured SQL for reports. But it did turn into a fairly interesting task, and it made me rethink how much JavaScript is really needed to make a dynamic UI.
This project got so crazy that users could rework the entire form by changing a few configuration values. Since this was a Microsoft shop, those values lived in XML, which an API endpoint fed into an Angular 2 frontend. The large Angular project had mappings of CSS classes and JavaScript functions that knew how to render the form without knowing what it would look like until the XML was loaded. The tool got so good that we could create a new form with hundreds of unique fields within minutes, have it correctly captured and stored in our MSSQL database, and let the SQL wizards find the information they needed. In a time before AI, when that mapping would otherwise have been wired up by hand, that was a pretty big win.
After that project, and the pain it brought, I was determined to get out of the frontend framework business. What follows is the approach I like to take when I need a UI.
I gravitate towards using the simplest tools possible, the ones with the longest history of "just working". That means keeping JavaScript to a minimum. The site should not need a node_modules folder to build. When you head in this direction, you'll find there is an older UI technique from before the SPA (single-page application) craze: the server generates fragments of HTML, and they get loaded into the DOM through requests to the server.
If you wrote ASP.NET MVC around 2011, you've done this already. A controller returned a partial view, and a data-ajax-update attribute on the link or form said where to put it. Rails had its own version of the same thing.
A nice attribute of this approach is that it leads to fast page loads. There are far fewer network round trips before anything shows up, as the data arrives already formatted and rendered into the HTML. There is no bundle to download and run first. No API calls to wait on after that. HTML also zips well. It got left behind as apps moved more of their logic into the browser. Mobile apps needed a JSON API anyway, and once the API existed, building the web client on it was the obvious next step.
I am here to say that for most of what we build, we don't need these large JavaScript frameworks to get that experience. htmx picks the old technique back up and improves on it. It isn't tied to a server framework or to jQuery, so it works the same from Rust as from anything else. Any element can make a request, on a click, a timer, or a page load, where the MVC helpers stopped at links and forms. And it looks after the address bar and the back button, which we used to wire up by hand. Combining this with Maud for templating and Bulma for CSS, we have the ability to build modern, dynamic web applications with a simple, minimal approach.
What follows is how I piece them together.
The shape of the thing
The finished version of everything below lives in the same repo as last time, under src/dashboard in tee8z/sqlite-web-starter. That one sits on top of the sqlite writer from the last post, and it has an asset pipeline and tests around it. The code here is a stripped down version of it, with the data in a Mutex rather than sqlite and the two libraries as plain files, so the only thing left to look at is the frontend.
The whole approach is in that picture. The browser asks for HTML and gets HTML, with no JSON in between and no second copy of the application living in the client. What htmx adds is small. Where the browser would throw the page away and load a new one, htmx makes the same request in the background and swaps the reply into whichever part of the page you point it at. The server's side of the deal is to notice who is asking. A plain browser gets a whole document, or a redirect after a form post, and htmx gets only the piece it is about to swap in. Everything from here on is working out those two replies.
Building it from scratch
1. The project
cargo new maud-htmx-demo
cd maud-htmx-demo
mkdir assets
curl -sL https://unpkg.com/htmx.org@4.0.0/dist/htmx.min.js -o assets/htmx.min.js
curl -sL https://unpkg.com/bulma@1.0.2/css/bulma.min.css -o assets/bulma.min.css
name = "maud-htmx-demo"
version = "0.1.0"
edition = "2024"
axum = "0.8"
maud = { version = "0.27", features = ["axum"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
That's the whole toolchain. There's no package.json, and those two curls are the nearest thing to an install step we'll have: one script and one stylesheet, pinned to a version and checked in beside the code.
2. A page is a function
Everything goes in src/main.rs. Here's the top of it, the data and the shell every page shares:
use std::sync::{Arc, Mutex};
use axum::{
Form, Router,
extract::State,
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Redirect, Response},
routing::{get, post},
};
use maud::{DOCTYPE, Markup, html};
use serde::Deserialize;
struct Item {
id: i64,
name: & str,
quantity: i64,
}
struct Store {
counter: i64,
inventory: Vec<Item>,
}
type Shared = Arc<Mutex<Store>>;
enum Page {
Counter,
Inventory,
}
impl Page {
fn title(self) -> & str {
match self {
Page::Counter => "Counter",
Page::Inventory => "Inventory",
}
}
}
fn base(page: Page, content: Markup) -> Markup {
! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { (page.title()) }
link rel="stylesheet" href="/assets/bulma.min.css";
script src="/assets/htmx.min.js" defer {}
}
body {
section class="section" {
div class="container is-max-desktop" {
h1 class="title" { "Who needs frontend frameworks?" }
(tabs(page, false))
main id="main-content" {
(content)
}
}
}
}
}
}
}
Maud's html! macro takes something HTML shaped and hands back a Markup value. Anything in parentheses is a Rust expression, escaped on the way out. The part I like is that a template is an ordinary function. base takes the page's content and returns the document around it, and the compiler checks it like any other code, so a typo is a build error rather than a blank spot on the page.
The classes are all Bulma's, and the tabs function it calls shows up in step 4.
The two libraries are embedded into the binary and served from it:
async fn bulma() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/css; charset=utf-8")],
!("../assets/bulma.min.css"),
)
}
async fn htmx() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/javascript; charset=utf-8")],
!("../assets/htmx.min.js"),
)
}
3. A button that saves
fn counter(value: i64) -> Markup {
! {
section id="counter" class="box" {
h2 class="title is-5" { "Saved counter" }
p class="is-size-1" { (value) }
form method="post" action="/increment"
hx-post="/increment" hx-target="#counter" hx-swap="outerHTML" hx-disable="find button" {
button id="increment" class="button is-primary" type="submit" { "Increment counter" }
}
}
}
}
async fn increment(headers: HeaderMap, State(store): State<Shared>) -> Response {
let value = {
let mut store = store.lock().unwrap();
store.counter += 1;
store.counter
};
if wants_fragment(&headers) {
reply(StatusCode::OK, counter(value))
} else {
Redirect::to("/").into_response()
}
}
Read that form twice. The first time, skip every attribute that starts with hx-. What's left is a form the way forms have worked since the nineties: it posts to /increment, the handler bumps the counter and redirects back to /, and the browser loads the page again with the new number on it. That works with JavaScript switched off, and it's the bottom branch of the handler.
Now read it again with the hx- attributes in. hx-post tells htmx to make that request itself. hx-target names where the reply goes, and hx-swap="outerHTML" says to replace the whole section and not just what's inside it. hx-disable="find button" disables the button until the reply arrives, so a double click is one increment and not two. The handler's top branch answers that request by calling counter again, the same function that rendered the section in the first place. There is one template for the counter. Both the page and the update come out of it.
In the starter repo this counter used to come with a 34 line script that caught the submit, called fetch, parsed the JSON, and wrote the number into the DOM. That script is deleted, and the one line of attributes is what replaced it.
The handler leans on two small helpers, and these are the only htmx specific Rust in the whole program:
/// Whether htmx asked for part of the page. Going back or forward, htmx
/// re-requests the URL and replaces the whole body, so that gets the document.
fn wants_fragment(headers: &HeaderMap) -> bool {
headers.contains_key("hx-request") && !headers.contains_key("hx-history-restore-request")
}
/// The same URL answers with a document or a fragment, so caches must keep
/// the two apart.
fn reply(status: StatusCode, markup: Markup) -> Response {
(
status,
[
(header::CACHE_CONTROL, "no-cache"),
(header::VARY, "HX-Request, HX-History-Restore-Request"),
],
markup,
)
.into_response()
}
htmx sends an HX-Request header with everything it asks for, and that's all wants_fragment looks at. The rest is about caches and the back button, and I'll come back to it at the end.
4. One URL, two replies
The counter swaps itself. Moving between pages is the same trick one level up.
fn tabs(current: Page, out_of_band: bool) -> Markup {
! {
nav id="tabs" class="tabs is-boxed" hx-swap-oob=[out_of_band.then_some("true")] {
ul {
@for (page, href) in [(Page::Counter, "/"), (Page::Inventory, "/inventory")] {
li class=[(page == current).then_some("is-active")] {
a href=(href) hx-get=(href) hx-target="#main-content" hx-push-url="true" {
(page.title())
}
}
}
}
}
}
}
fn page(headers: &HeaderMap, page: Page, content: Markup) -> Response {
let markup = if wants_fragment(headers) {
! {
title { (page.title()) }
(tabs(page, true))
(content)
}
} else {
base(page, content)
};
reply(StatusCode::OK, markup)
}
async fn counter_page(headers: HeaderMap, State(store): State<Shared>) -> Response {
let value = store.lock().unwrap().counter;
page(&headers, Page::Counter, counter(value))
}
async fn inventory_page(headers: HeaderMap, State(store): State<Shared>) -> Response {
let store = store.lock().unwrap();
page(
&headers,
Page::Inventory,
inventory(&store.inventory, &StockForm::default()),
)
}
Each tab is a link with an href, so without JavaScript it's a link. With htmx, hx-get fetches the same URL in the background, hx-target drops the reply into <main>, and hx-push-url puts the URL in the address bar so the page can be bookmarked and the back button has somewhere to go.
page is where the two replies part ways. A plain request gets base wrapped around the content. An htmx request gets the content with two extras: a <title>, which htmx lifts out and uses, and the tabs again, marked hx-swap-oob so htmx replaces the ones already on the page and the right tab ends up active.
Notice that nothing in here is a router on the client. /inventory is a real URL that returns a real page whether you click to it, type it in, or send it to a friend.
5. A form that comes back with its errors
This is the part of the job I opened with, and the reason I ended up here. A form needs to be validated. When it is wrong it needs to come back with what you typed still in it and the complaint next to the right field. In an SPA that means the rules exist twice, once in JavaScript for the nice errors and once on the server because you can't trust the client.
Here there is one set:
struct StockForm {
item: String,
quantity: String,
item_error: Option<& str>,
quantity_error: Option<& str>,
saved: bool,
}
impl StockForm {
/// The item's id and its new quantity, or errors to show beside the fields.
fn validate(&mut self) -> Option<(i64, i64)> {
let id = self.item.trim().parse::<i64>().ok();
if id.is_none() {
self.item_error = Some("Choose an item.");
}
let quantity = self
.quantity
.trim()
.parse::<i64>()
.ok()
.filter(|quantity| (0..=9999).contains(quantity));
if quantity.is_none() {
self.quantity_error = Some("Enter a whole number from 0 to 9999.");
}
id.zip(quantity)
}
}
async fn update_stock(
headers: HeaderMap,
State(store): State<Shared>,
Form(mut form): Form<StockForm>,
) -> Response {
let fragment = wants_fragment(&headers);
let mut store = store.lock().unwrap();
if let Some((id, quantity)) = form.validate() {
match store.inventory.iter_mut().find(|item| item.id == id) {
Some(item) => {
item.quantity = quantity;
if !fragment {
return Redirect::to("/inventory").into_response();
}
form = StockForm {
saved: true,
..StockForm::default()
};
}
None => form.item_error = Some("That item is no longer in the inventory."),
}
}
let status = if form.item_error.is_some() || form.quantity_error.is_some() {
StatusCode::UNPROCESSABLE_ENTITY
} else {
StatusCode::OK
};
let section = inventory(&store.inventory, &form);
let markup = if fragment {
section
} else {
base(Page::Inventory, section)
};
reply(status, markup)
}
StockForm does double duty. It's what serde parses the posted fields into, and it's what the template renders, errors included. When validation fails the handler renders the same section again, with the form holding everything that was typed, and sends it with a 422. htmx swaps it in like any other reply. When it passes, htmx gets the section with the new quantity in the table, and a plain form post gets a redirect.
The markup is the longest function in the program, and most of it is Bulma's field structure:
fn inventory(items: &[Item], form: &StockForm) -> Markup {
! {
section id="inventory" class="box" {
h2 class="title is-5" { "Inventory" }
table class="table is-fullwidth is-striped" {
thead { tr { th { "Item" } th { "Quantity" } } }
tbody hx-get="/fragments/inventory-rows" hx-trigger="every 5s" {
(rows(items))
}
}
form method="post" action="/inventory"
hx-post="/inventory" hx-target="#inventory" hx-swap="outerHTML" hx-disable="find button" {
div class="field" {
label class="label" for="item" { "Item" }
div class="select" {
select id="item" name="item" {
@for item in items {
option value=(item.id) selected[form.item == item.id.to_string()] {
(item.name)
}
}
}
}
@if let Some(error) = form.item_error {
p class="help is-danger" { (error) }
}
}
div class="field" {
label class="label" for="quantity" { "Quantity" }
input id="quantity" name="quantity" inputmode="numeric" value=(form.quantity)
class=(if form.quantity_error.is_some() { "input is-danger" } else { "input" });
@if let Some(error) = form.quantity_error {
p class="help is-danger" { (error) }
}
}
button id="save" class="button is-primary" type="submit" { "Save quantity" }
@if form.saved {
p class="help is-success" role="status" { "Saved." }
}
}
}
}
}
The dropdown is the bit worth a second look. It's a @for over whatever is in the inventory. That project from the top of this post, with its XML configuration and its Angular mappings, was a very long way round to this: a form is a function from data to HTML, and if the data changes the form changes with it.
6. Rows that refresh themselves
One line of the table above hasn't been explained yet, the <tbody>:
fn rows(items: &[Item]) -> Markup {
! {
@for item in items {
tr { td { (item.name) } td { (item.quantity) } }
}
}
}
async fn inventory_rows(State(store): State<Shared>) -> Markup {
rows(&store.lock().unwrap().inventory)
}
hx-trigger="every 5s" makes the request on a timer, and with no hx-target the reply goes into the element that asked. So every five seconds the table body fetches its own rows. The rows function is called from two places, the full table and this handler, the same way counter was. That pattern is the one to hold on to: make the piece that changes its own function, and give it its own URL.
7. Styles and scripts beside the template
So far the page has no CSS or JavaScript of its own, and sooner or later it will want a little of each. Where those files live is the part of this setup I'm fondest of. The thing SPA frameworks get right is the component: the markup, the styles, and the behavior for one piece of the UI sitting together in one place. You can have that here too, and what makes it possible is a build.rs.
A piece of UI that needs more than markup becomes a folder:
build.rs
src/
main.rs
templates/
mod.rs
theme_toggle/
mod.rs the markup
theme_toggle.css its styles
theme_toggle.js its behavior
src/templates/mod.rs is one line:
pub mod theme_toggle;
The three files in the folder are about as small as a component gets. The markup, in src/templates/theme_toggle/mod.rs:
use maud::{Markup, html};
pub fn theme_toggle() -> Markup {
! {
button id="theme-toggle" class="button is-small" type="button" { "Light / dark" }
}
}
Its styles, in theme_toggle.css:
#theme-toggle { float: right; }
And its behavior, in theme_toggle.js:
// Bulma follows the system's color scheme until <html> carries data-theme.
(() => {
const root = document.documentElement;
const saved = localStorage.getItem("theme");
if (saved) root.dataset.theme = saved;
// One listener on the document outlives every swap htmx makes.
document.addEventListener("click", (event) => {
if (!event.target.closest("#theme-toggle")) return;
const dark = root.dataset.theme
? root.dataset.theme === "dark"
: matchMedia("(prefers-color-scheme: dark)").matches;
root.dataset.theme = dark ? "light" : "dark";
localStorage.setItem("theme", root.dataset.theme);
});
})();
Bulma already follows the system's light or dark setting, and a data-theme attribute on <html> overrides it, so the whole feature is flipping one attribute and remembering the choice.
Now the part that ties the three together. build.rs sits beside Cargo.toml, and Cargo runs it before it compiles the crate:
use std::{
env, fs,
hash::{DefaultHasher, Hash, Hasher},
io,
path::{Path, PathBuf},
};
fn main() -> io::Result<()> {
// Run again when anything under src/templates changes, new files included.
!("cargo::rerun-if-changed=src/templates");
let out = PathBuf::from(env::var("OUT_DIR").unwrap());
let mut files = Vec::new();
collect(Path::new("src/templates"), &mut files)?;
files.sort();
let mut generated = String::new();
for (extension, constant) in [("css", "SITE_CSS"), ("js", "SITE_JS")] {
// Every file of this kind, wherever it sits, joined into one bundle.
let mut bundle = String::new();
for file in &files {
if file.extension().is_some_and(|found| found == extension) {
bundle.push_str(&fs::read_to_string(file)?);
bundle.push('\n');
}
}
fs::write(out.join(!("site.{extension}")), &bundle)?;
// The URL changes whenever the bytes do, so a browser may keep it forever.
let mut hasher = DefaultHasher::new();
bundle.hash(&mut hasher);
let hash = hasher.finish();
generated.push_str(&!(
"pub const {constant}_URL: &str = \"/assets/site.{hash:016x}.{extension}\";\n\
pub const {constant}: &str = include_str!(concat!(env!(\"OUT_DIR\"), \"/site.{extension}\"));\n"
));
}
fs::write(out.join("assets.rs"), generated)
}
fn collect(directory: &Path, files: &mut Vec<PathBuf>) -> io::Result<()> {
for entry in fs::read_dir(directory)? {
let path = entry?.path();
if path.is_dir() {
collect(&path, files)?;
} else {
files.push(path);
}
}
Ok(())
}
It walks src/templates, joins every .css file it finds into one stylesheet and every .js file into one script, and then writes four lines of Rust with a URL for each bundle and an include_str! of its contents. Each URL has a hash of the bundle in it.
Back in src/main.rs, under the imports, that generated file gets pulled in:
mod templates;
use templates::theme_toggle::theme_toggle;
// SITE_CSS, SITE_JS and their URLs, written by build.rs.
!(!(!("OUT_DIR"), "/assets.rs"));
Two handlers serve the bundles:
/// A hashed URL never changes meaning, so the browser never needs to ask again.
fn bundle(content_type: & str, body: & str) -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, content_type),
(header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
],
body,
)
}
async fn site_css() -> impl IntoResponse {
bundle("text/css; charset=utf-8", SITE_CSS)
}
async fn site_js() -> impl IntoResponse {
bundle("text/javascript; charset=utf-8", SITE_JS)
}
And base picks up three lines, two in the head and the button in the body:
fn base(page: Page, content: Markup) -> Markup {
! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { (page.title()) }
link rel="stylesheet" href="/assets/bulma.min.css";
link rel="stylesheet" href=(SITE_CSS_URL); // new
script src="/assets/htmx.min.js" defer {}
script src=(SITE_JS_URL) defer {} // new
}
body {
section class="section" {
div class="container is-max-desktop" {
(theme_toggle()) // new
h1 class="title" { "Who needs frontend frameworks?" }
(tabs(page, false))
main id="main-content" {
(content)
}
}
}
}
}
}
}
That's the whole pipeline. Adding a stylesheet or a script means creating a file next to the template it belongs to, with no list to add it to and no import to write. Since the URL changes whenever the contents do, the browser can keep the file for a year and never ask again. And it all happens inside the build you already have, with the compiler as the bundler.
8. Run it
The last piece is main:
async fn main() {
let store = Arc::new(Mutex::new(Store {
counter: 0,
inventory: ![
Item { id: 1, name: "Notebooks", quantity: 12 },
Item { id: 2, name: "Pencils", quantity: 48 },
Item { id: 3, name: "Mugs", quantity: 6 },
],
}));
let router = Router::new()
.route("/", get(counter_page))
.route("/increment", post(increment))
.route("/inventory", get(inventory_page).post(update_stock))
.route("/fragments/inventory-rows", get(inventory_rows))
.route("/assets/bulma.min.css", get(bulma))
.route("/assets/htmx.min.js", get(htmx))
.route(SITE_CSS_URL, get(site_css))
.route(SITE_JS_URL, get(site_js))
.with_state(store);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
!("listening on http://localhost:3000");
axum::serve(listener, router).await.unwrap();
}
cargo run
Open http://localhost:3000 and try to break it:
- Click the counter with the network tab open. Each click is one small POST, and the reply is a few hundred bytes of HTML.
- Save a quantity of
lots. The form comes back with the error and withlotsstill in the box. - Open the inventory in two windows and change a quantity in one. The other catches up within five seconds.
- Use the tabs, then the back button, then reload. The URL is always right.
- Add a
.cssfile anywhere undersrc/templates, run it again, and view source. The stylesheet's URL has changed and no Rust was edited. - Turn JavaScript off and do all of it again. The polling and the theme button stop, and everything else still works.
That last one is my favorite test. If the page works with JavaScript off, then htmx is only ever making it nicer.
What to watch for
It isn't all free. These are the things that cost me time.
- Each element names its own target. htmx 4 doesn't pass
hx-targetdown from a parent, which is why every tab carries its own. If a click replaces the link itself with a whole page, this is what happened. - One URL with two replies confuses caches. That's what
replyis for. It sendsVary: HX-Request, so a cache keeps the document and the fragment apart. The back button is its own case: htmx asks for the URL again and expects the whole document, which is the second half ofwants_fragment. - Error replies get swapped in too. That's what makes the
422form work, and it means a plain text500will land in the middle of your page just as readily. Every error an htmx request can get back should be HTML that fits where it's going. - Swapped elements take their listeners with them. A script that attaches to a button at page load loses it the first time htmx replaces that button. Listen on the document, or set things up again on
htmx:after:swap.
Where it goes from here
The dashboard in the starter is this demo with the parts you'd want before putting it in front of people. Its build.rs also minifies the bundles and gzips them ahead of time, which takes Bulma from 677 kB as a file to 65 kB on the wire. Its templates are tested by calling them and checking the string that comes back, with no browser involved. A write that gets turned away still answers with HTML, so the message lands under the button. And the one script it has left draws a chart, which is a fair thing to ask a script to do.
Bigger projects of mine have pushed the same patterns further:
- A third reply. htmx also sends an
HX-Targetheader naming where the reply is headed, so one URL can return a single named part of a page, such as the list a search box filters. - Placeholders that wait. A fragment that depends on something slow can come back right away as a placeholder with
hx-trigger="load delay:2s", which asks again by itself and falls back to a retry button. - A strict Content-Security-Policy. With every behavior in a script file, a page can allow scripts from its own origin and nowhere else.
And sometimes the answer is less. maincopy, which this blog runs on, is Maud with no htmx at all. An article page has nothing to swap, so it gets nothing to swap with. More on that project in the future.
So who needs frontend frameworks?
Some people do, to be fair about it. If you're building a drawing tool, or a spreadsheet, or anything else where the page is the application and the server is somewhere to save, then the state really does live in the browser and you want the tools made for that.
Most of what I get asked to build isn't that. It's a table, a form, a few numbers that should stay fresh, and a way to move between them. For that, the whole frontend above is about four hundred lines of Rust, sixteen of JavaScript, one of CSS, and two files I downloaded once. It builds with the one command the backend already needed. When something is wrong on the page, I can read the response in the network tab and see the HTML that's wrong.
What you give up is the feeling of an app that never talks to the server. Every click here is a request, so it's as fast as your server is. With the server being a Rust binary reading from a local sqlite file, I'm comfortable with that trade.
Offline is the other thing people expect to lose, and you don't have to. Nothing in this setup stops you adding a service worker that stashes the pages as they're visited, holds on to the form posts made while the connection is down, and sends them to the Rust API when it comes back. proofofscore has the first half of that already: its service worker caches the pages and assets so practice mode works offline, and it steps out of the way of htmx requests so a fragment never gets served where a page should be. I'd treat it as an extension you add when you need it, and not a reason to start with a framework.
Have a poke around the repo, turn JavaScript off, and see how much still works.
All the best, and enjoy your tinkering.