HTMX Crash Course
Some web apps don't actually need a full frontend framework. You just need forms that don't reload the page, buttons that hit the server, and little pieces of the UI that update dynamically.
HTMX lets you do all of that with almost zero JavaScript on the frontend. You keep your backend rendering HTML, sprinkle in a few attributes, and you've suddenly got dynamic behavior that feels like a SPA without turning your app into a JavaScript project.
What is HTMX?
HTMX is a tiny JavaScript library (about 14KB) that lets you add dynamic behavior to HTML using custom attributes such as hx-get, hx-post, hx-trigger, and hx-target.
Instead of writing JavaScript to make fetch or XMLHttpRequest calls, you declare what should happen in your HTML, and HTMX handles the requests and updates the page with the server's response.
It's perfect if you like server-rendered apps (Express, Django, Go, Laravel, etc.) and want interactivity without pulling in React, Vue, or writing a bunch of front-end JS.
Core Features
hx-get, hx-post, hx-put, hx-patch, hx-delete
These attributes tell HTMX to make HTTP requests when a user interacts with an element. You can attach them to any HTML element, not just forms or links.
Example:
<button hx-get="/users">Fetch Users</button>
When the button is clicked, HTMX makes a GET request to /users and, by default, swaps the element's HTML with the server response. Use hx-post, hx-put, hx-patch, or hx-delete the same way when you want to modify data on the server.
Use these when you want small, focused server interactions: loading a list, saving a form, deleting a record, etc., without a full page reload.
hx-trigger
hx-trigger controls when the request should fire. If you omit it, HTMX uses sensible defaults: click for buttons/links, submit for forms.
You can use events like click, mouseover, submit, input, or even polling expressions such as every 5s.
Examples:
<button hx-get="/users" hx-trigger="mouseover">Hover to fetch users</button>
<input
hx-post="/search"
hx-trigger="input changed delay:500ms"
/>
Use this when you need fine-grained control: search-as-you-type, validate-on-blur, or periodic polling.
hx-target
By default, HTMX swaps the element that triggered the request with the response. hx-target lets you tell it to update some other element instead.
<button hx-get="/users" hx-target="#users-list">Fetch Users</button>
<div id="users-list"></div>
Here, the response HTML from /users will be rendered inside #users-list, and the button stays on the page. You can target by CSS selector or use special values like this, closest .class, next, etc.
Use this every time you want to keep the triggering element (like a button or input) and render results somewhere else.
hx-swap
hx-swap tells HTMX how to insert the response into the target element. Common values:
innerHTML(default) - replace the inside of the targetouterHTML- replace the target element itselfbeforebegin,afterbegin,beforeend,afterend- insert around the target
Example:
<button
hx-get="/users"
hx-swap="outerHTML"
>
Fetch Users
</button>
Here, the entire button is replaced with the HTML returned by /users (for example, a <ul> of users).
Use hx-swap when you need fine control over whether you replace or append content.
hx-indicator
hx-indicator connects an element (like a spinner) to the request lifecycle. When HTMX is waiting on the server, it adds the CSS class htmx-indicator to the referenced element, which you can show via CSS, or you can rely on the special class name directly if you nest it.
Example pattern:
<span id="loading" class="htmx-indicator">
Loading...
</span>
<button
hx-get="/users"
hx-target="#users-list"
hx-indicator="#loading"
>
Fetch Users
</button>
When the request is in-flight, that span becomes visible (depending on your CSS). Use this for loaders on long-running actions.
hx-vals
hx-vals lets you send additional data with the request, without adding hidden inputs.
<button
hx-get="/users"
hx-vals='{"limit": 5}'
>
Fetch 5 Users
</button>
On the server, that shows up like any other query parameter or form value (e.g. req.query.limit in Express). Use this when you need to parameterize requests from otherwise static elements.
How to Use It
Starting Simple: Fetching and Rendering a User List
I started with a basic Express server that serves static files from a public folder and listens on port 3000:
import express from "express";
const app = express();
app.use(express.static("public"));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.listen(3000);
In public/index.html, I included HTMX via CDN:
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
Then I created a button and a container where I wanted to render users:
<button
hx-get="/users"
hx-target="#users"
>
Fetch Users
</button>
<div id="users"></div>
On the backend, I added a /users route that returns HTML, not JSON:
app.get("/users", (req, res) => {
const users = [
{ id: 1, name: "John Doe" },
{ id: 2, name: "Bob Williams" },
{ id: 3, name: "Shannon Jackson" }
];
const list = users
.map(user => `<li>${user.name}</li>`)
.join("");
res.send(`
<h1>Users</h1>
<ul>${list}</ul>
`);
});
Now, when I click the button, HTMX makes a GET request to /users, takes the returned markup, and injects it into the #users div. No frontend JS, no manual DOM manipulation.
Result: a working "Fetch Users" button that dynamically loads and renders a server-generated user list without reloading the page.
Advanced: Search-as-You-Type Over a Contact List
For something more dynamic, I built a search box that filters contacts as I type and shows results in a table.
In the HTML, I created an input and a table body that will hold the results:
<input
type="text"
name="searchTerm"
placeholder="Search contacts..."
hx-post="/search"
hx-trigger="input changed delay:500ms"
hx-target="#search-results"
hx-indicator="#loading"
/>
<span id="loading" class="htmx-indicator">Searching...</span>
<tbody id="search-results"></tbody>
The key pieces here:
hx-post="/search"sends the current input value to the server.hx-trigger="input changed delay:500ms"fires after typing stops for 500ms, so it doesn't spam the server.hx-target="#search-results"tells HTMX to replace the table body with the response.hx-indicator="#loading"shows the loading indicator while the request is running.
On the backend, I defined a list of contacts and a search route:
const contacts = [
{ name: "John Doe", email: "john@example.com" },
{ name: "Jane Doe", email: "jane@example.com" },
{ name: "Leanne Graham", email: "leanne@example.com" }
];
app.post("/search", (req, res) => {
const term = (req.body.searchTerm || "").toLowerCase();
if (!term) return res.send("");
const results = contacts.filter(c =>
c.name.toLowerCase().includes(term) ||
c.email.toLowerCase().includes(term)
);
const rows = results
.map(c => `
<tr>
<td>${c.name}</td>
<td>${c.email}</td>
</tr>
`)
.join("");
res.send(rows);
});
Now as I type into the input, HTMX sends the value to /search, the server filters contacts, returns rows, and the table body updates in place.
Result: a responsive, debounced search widget hitting the server on every change, with zero custom frontend JavaScript.
Pro Tips
- Return HTML, not JSON: HTMX shines when the server returns HTML fragments ready to render. Let your backend templating (or string building) do the heavy lifting.
- Keep routes small and focused: Each
hx-*interaction maps nicely to a small backend endpoint that returns one UI fragment. - Use
hx-targetaggressively: Don't overwrite your buttons and inputs unless you really mean to. Point responses at specific containers. - Leverage
hx-indicatorwith slow endpoints: A tiny spinner or "Loading..." text makes your app feel much smoother when requests take noticeable time. - Validate on the server, show inline feedback: For forms like email validation, send just the field value, validate server-side, and return a snippet of markup (a colored message under the field).
- Remember it's still HTTP: You can use all verbs (GET, POST, PUT, PATCH, DELETE) and pass extra data with
hx-valsor standard form fields.
When to Use HTMX vs Alternatives
HTMX fits best when you already have, or want, a server-rendered app and just need dynamic pieces: partial page updates, inline validation, small widgets, or periodic polling.
Compared to heavy SPA frameworks like React or Vue, HTMX keeps the complexity on the server side and avoids a frontend build pipeline. There's no client-side state management library, no routing setup, and far less JavaScript to maintain.
Compared to tiny libraries like Alpine.js, HTMX is more about server communication and partial HTML replacement, while Alpine is more about client-side state and logic. They actually pair well together: Alpine for local UI behavior, HTMX for talking to the server.
If your app is a highly interactive, offline-capable client with complex shared state across many components, a full SPA framework probably makes more sense. If your app is primarily request/response with HTML rendered on the server, HTMX gives you dynamic behavior with a tiny footprint and a very gentle learning curve.
๐บ This article was adapted from HTMX Crash Course | Dynamic Pages Without Writing Any JavaScript