HTMX is a library that enables dynamic interactivity in web applications using a hypermedia-first approach. Instead of writing complex JavaScript, HTMX lets you build modern user interfaces using HTML attributes. This comprehensive guide covers everything you need to know about HTMX.
What is HTMX?
HTMX is a JavaScript library (under 14KB gzipped) that extends HTML with powerful attributes for handling AJAX requests, WebSocket connections, and Server-Sent Events directly from HTML elements.
<!-- Traditional approach: Write JavaScript -->
<button id="load-more">Load More</button>
<script>
document.getElementById('load-more').addEventListener('click', async () => {
const response = await fetch('/api/posts');
const posts = await response.json();
// Render posts to DOM...
});
</script>
<!-- HTMX approach: Just HTML -->
<button hx-get="/api/posts" hx-target="#posts" hx-swap="beforeend">
Load More
</button>
<div id="posts"></div>
Core Philosophy
HTMX embraces the hypermedia-driven architecture:
- HTML as the API - Server returns HTML fragments, not JSON
- Progressive enhancement - Works without JavaScript (with graceful degradation)
- Simplicity - No build steps, no virtual DOM, no complex state management
- Small footprint - Single library replaces React, Vue, or Angular for many use cases
<!-- HTMX CDN - Add to any HTML page -->
<script src="https://unpkg.com/[email protected]"></script>
HTMX Attributes
HTMX extends HTML with attributes that begin with hx-. Each attribute controls a specific aspect of the AJAX behavior.
Request Attributes
<!-- hx-get: Make GET request -->
<a hx-get="/users" hx-target="#user-list">Load Users</a>
<!-- hx-post: Make POST request -->
<form hx-post="/contact" hx-target="#response">
<input type="email" name="email" required>
<button type="submit">Subscribe</button>
</form>
<!-- hx-put: Make PUT request -->
<button hx-put="/users/123" hx-confirm="Are you sure?">Update User</button>
<!-- hx-delete: Make DELETE request -->
<button hx-delete="/posts/456" hx-swap="outerHTML">Delete Post</button>
<!-- hx-patch: Make PATCH request -->
<button hx-patch="/settings" hx-include="[name='theme']">Save Settings</button>
Target and Swap
<!-- hx-target: Where to insert the response -->
<div hx-get="/notifications" hx-target="#notification-area">
Click to load notifications
</div>
<!-- Special targets -->
<button hx-get="/details" hx-target="this">Replace this button</button>
<button hx-get="/next" hx-target="next .card">Target next sibling card</button>
<!-- hx-swap: How to insert the response -->
<div hx-get="/comments" hx-swap="innerHTML">Load comments</div>
<!-- Swap options -->
<!-- innerHTML - Insert inside target (default) -->
<!-- outerHTML - Replace the target entirely -->
<!-- beforebegin - Insert before target -->
<!-- afterbegin - Insert as first child -->
<!-- beforeend - Insert as last child -->
<!-- afterend - Insert after target -->
<!-- delete - Remove the target -->
<!-- none - Don't swap (useful for events) -->
Trigger Attributes
<!-- hx-trigger: What event triggers the request -->
<input type="text" hx-get="/search" hx-trigger="keyup changed delay:300ms">
<!-- Event modifiers -->
<button hx-post="/click" hx-trigger="click">Click me</button>
<form hx-post="/submit" hx-trigger="submit">...</form>
<!-- Multiple triggers -->
<div hx-get="/updates" hx-trigger="click, every 30s">
<!-- Condition-based triggers -->
<input type="text" hx-get="/validate" hx-trigger="keyup changed">
<!-- Initial load -->
<div hx-get="/initial-data" hx-trigger="load">
Extended Headers
<!-- hx-headers: Add custom headers -->
<button hx-get="/api" hx-headers='{"X-Custom-Token": "abc123"'>
Fetch with headers
</button>
<!-- hx-request: Configure request -->
<button hx-get="/slow" hx-request='{"timeout": 5000}'>
5 second timeout
</button>
<!-- hx-vals: Add JSON values -->
<button hx-post="/action" hx-vals='{"source": "button"}'>
With extra data
</button>
Working with Forms
HTMX handles form submissions elegantly with HTML-centric behavior.
Basic Form Submission
<!-- Simple form with HTMX -->
<form hx-post="/contact" hx-target="#form-response" hx-swap="innerHTML">
<div>
<label for="name">Name:</label>
<input type="text" id="name" name="name" required>
</div>
<div>
<label for="email">Email:</label>
<input type="email" id="email" name="email" required>
</div>
<div>
<label for="message">Message:</label>
<textarea id="message" name="message" rows="4"></textarea>
</div>
<button type="submit">Send Message</button>
</form>
<div id="form-response"></div>
File Upload
<!-- File upload with progress -->
<form hx-post="/upload" hx-target="#upload-result" enctype="multipart/form-data">
<input type="file" name="document" accept=".pdf,.doc,.docx">
<button type="submit">Upload</button>
</form>
<div id="upload-result"></div>
<!-- Progress indicator -->
<div hx-get="/upload/progress" hx-trigger="every 200ms" hx-target="this">
<progress id="upload-progress" value="0" max="100"></progress>
</div>
Form Validation
<!-- Inline validation -->
<form hx-post="/validate" hx-target="this" hx-swap="outerHTML">
<input type="text"
name="username"
hx-post="/validate/username"
hx-trigger="change"
hx-target="next .error"
placeholder="Choose username">
<span class="error"></span>
<input type="email" name="email" placeholder="Your email">
<span class="error"></span>
<button type="submit">Register</button>
</form>
Dynamic Content Loading
Infinite Scroll
<!-- Infinite scroll implementation -->
<div id="posts-container">
<!-- Initial posts loaded server-side -->
<article>Post 1 content...</article>
<article>Post 2 content...</article>
</div>
<!-- Load more trigger -->
<div hx-get="/posts?page=2"
hx-trigger="revealed"
hx-swap="beforeend"
hx-target="#posts-container">
<img src="/images/loading.gif" alt="Loading more...">
</div>
Tab Interface
<!-- Simple tabs with HTMX -->
<div class="tabs">
<button hx-get="/tab/dashboard"
hx-target="#tab-content"
hx-swap="innerHTML"
class="active">
Dashboard
</button>
<button hx-get="/tab/reports"
hx-target="#tab-content"
hx-swap="innerHTML">
Reports
</button>
<button hx-get="/tab/settings"
hx-target="#tab-content"
hx-swap="innerHTML">
Settings
</button>
</div>
<div id="tab-content">
<!-- Tab content loads here -->
<p>Select a tab to load content.</p>
</div>
Search with Debounce
<!-- Search with debounced requests -->
<div class="search-container">
<input type="search"
name="q"
placeholder="Search products..."
hx-get="/search"
hx-trigger="keyup changed delay:300ms, search"
hx-target="#search-results"
hx-indicator="#searching">
<img id="searching"
class="htmx-indicator"
src="/images/spinner.gif"
alt="Searching...">
</div>
<div id="search-results">
<!-- Search results appear here -->
</div>
<style>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
.htmx-request.htmx-indicator { display: inline; }
</style>
Modal Dialogs
<!-- Click to open modal -->
<button hx-get="/modal/content"
hx-target="body"
hx-swap="beforeend">
Open Modal
</button>
<!-- Modal template (server returns this) -->
<!--
<div id="modal" class="modal">
<div class="modal-content">
<span hx-get="/close/modal" hx-target="closest #modal" hx-swap="outerHTML" class="close">×</span>
<h2>Modal Title</h2>
<p>Modal content...</p>
</div>
</div>
-->
Server-Side Integration
HTMX works with any server-side language or framework. The server returns HTML fragments, not JSON.
Python (Flask) Example
from flask import Flask, render_template_string, request
app = Flask(__name__)
# Simple page with HTMX
@app.route('/')
def index():
return render_template_string('''
<!DOCTYPE html>
<html>
<head>
<title>HTMX Demo</title>
<script src="https://unpkg.com/[email protected]"></script>
<style>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
</style>
</head>
<body>
<h1>HTMX Demo</h1>
<button hx-get="/time" hx-target="#time" hx-swap="innerHTML">
Get Current Time
</button>
<span id="time">-</span>
<img class="htmx-indicator" src="/loading.gif">
<form hx-post="/greet" hx-target="#greeting">
<input type="text" name="name" placeholder="Your name">
<button type="submit">Greet</button>
</form>
<div id="greeting"></div>
</body>
</html>
''')
@app.route('/time')
def get_time():
from datetime import datetime
return f'<span>{datetime.now().strftime("%H:%M:%S")}</span>'
@app.route('/greet', methods=['POST'])
def greet():
name = request.form.get('name', 'World')
return f'<div class="greeting">Hello, {name}!</div>'
if __name__ == '__main__':
app.run(debug=True)
Node.js (Express) Example
const express = require('express');
const app = express();
app.use(express.urlencoded({ extended: true }));
// Main page
app.get('/', (req, res) => {
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>HTMX Express Demo</title>
<script src="https://unpkg.com/[email protected]"></script>
</head>
<body>
<h1>HTMX + Express Demo</h1>
<button hx-get="/api/random" hx-target="#random-number">
Generate Random Number
</button>
<span id="random-number">-</span>
<form hx-post="/api/reverse" hx-target="#reversed">
<input type="text" name="text" placeholder="Type something...">
<button type="submit">Reverse</button>
</form>
<div id="reversed"></div>
</body>
</html>
`);
});
// HTMX endpoints return HTML fragments
app.get('/api/random', (req, res) => {
const num = Math.floor(Math.random() * 100);
res.send(`<span>${num}</span>`);
});
app.post('/api/reverse', (req, res) => {
const text = req.body.text || '';
const reversed = text.split('').reverse().join('');
res.send(`<div>Reversed: <strong>${reversed}</strong></div>`);
});
app.listen(3000, () => console.log('Server running on port 3000'));
Go Example
package main
import (
"fmt"
"net/http"
"time"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head>
<title>HTMX Go Demo</title>
<script src="https://unpkg.com/[email protected]"></script>
</head>
<body>
<h1>HTMX + Go Demo</h1>
<button hx-get="/time" hx-target="#time-display">
Get Server Time
</button>
<div id="time-display">-</div>
<form hx-post="/calc" hx-target="#calc-result">
<input type="number" name="a" placeholder="A" style="width: 60px">
<span>+</span>
<input type="number" name="b" placeholder="B" style="width: 60px">
<button type="submit">Add</button>
</form>
<div id="calc-result"></div>
</body>
</html>
`))
})
http.HandleFunc("/time", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, "<span>%s</span>", time.Now().Format("15:04:05"))
})
http.HandleFunc("/calc", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
var a, b int
fmt.Sscanf(r.FormValue("a"), "%d", &a)
fmt.Sscanf(r.FormValue("b"), "%d", &b)
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, "<div>Result: <strong>%d</strong></div>", a+b)
})
http.ListenAndServe(":8080", nil)
}
Ruby on Rails Example
# config/routes.rb
Rails.application.routes.draw do
root to: 'demo#index'
get 'demo/time', to: 'demo#time'
post 'demo/search', to: 'demo#search'
end
# app/controllers/demo_controller.rb
class DemoController < ApplicationController
def index
# Renders the main page with HTMX
end
def time
render plain: "<span>#{Time.current.strftime('%H:%M:%S')}</span>"
end
def search
query = params[:q]
results = Article.where("title LIKE ?", "%#{query}%").limit(10)
render partial: 'results', locals: { results: results }
end
end
# app/views/demo/index.html.erb
<h1>HTMX Rails Demo</h1>
<button hx-get="/demo/time" hx-target="#time">
Get Time
</button>
<span id="time">-</span>
<%= form_with url: "/demo/search",
method: :post,
data: { hx_post: "/demo/search", hx_target: "#results" } do |f| %>
<%= f.text_field :q, placeholder: "Search articles..." %>
<%= f.submit "Search" %>
<% end %>
<div id="results"></div>
Advanced Patterns
Error Handling
<!-- hx-on: Handle errors with inline JavaScript -->
<button hx-get="/error-prone"
hx-target="#result"
hx-swap="innerHTML"
hx-on::after-request="if (this.htmx.isError(this)) { alert('Request failed!'); }">
Test Error
</button>
<!-- hx-error: Show error message -->
<form hx-post="/api" hx-target="#response">
<input type="text" name="data">
<button type="submit">Submit</button>
</form>
<div id="response"></div>
<!-- Server returns error in header for HTMX to display -->
Loading States
<!-- Using hx-indicator class -->
<button hx-get="/slow-api" hx-target="#result">
<span class="htmx-indicator">Loading...</span>
<span class="htmx-inverse-indicator">Click Me</span>
</button>
<style>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
.htmx-request .htmx-inverse-indicator { display: none; }
</style>
<!-- Using CSS transitions -->
<div hx-get="/content" hx-trigger="load" class="fade-in">
Loading...
</div>
<style>
.fade-in { opacity: 0; transition: opacity 0.3s; }
.htmx-request .fade-in { opacity: 0.5; }
.htmx-settle .fade-in { opacity: 1; }
</style>
Optimistic UI
<!-- Optimistic toggle -->
<button hx-post="/toggle-like/123"
hx-swap="outerHTML"
class="like-button">
<span class="icon">โก</span> Like
</button>
<!-- Server returns updated button state -->
<!-- Before: <button ...>โก Like</button> -->
<!-- After: <button ...>โฅ Liked</button> -->
<!-- Using hx-on for immediate feedback -->
<button hx-delete="/item/123"
hx-swap="outerHTML"
hx-on::before-request="this.innerHTML='Deleting...'">
Delete Item
</button>
WebSocket Integration
<!-- Real-time updates with WebSocket -->
<div hx-ws="connect:/ws">
<div id="messages"></div>
<form hx-ws="send">
<input type="text" name="message" placeholder="Type a message...">
<button type="submit">Send</button>
</form>
</div>
<!-- Server sends HTML fragments via WebSocket -->
<!--
<div hx-ws="swap:messages">New message content</div>
-->
Server-Sent Events
<!-- Real-time updates with SSE -->
<div hx-sse="connect:/sse/updates">
<div hx-sse="swap:message" id="live-updates">
Waiting for updates...
</div>
</div>
<!-- Server sends: -->
<!-- event: message -->
<!-- data: <div>New data received!</div> -->
HTMX vs Traditional SPAs
| Aspect | HTMX | React/Vue/Angular |
|---|---|---|
| Bundle Size | ~14KB | 30-100+ KB |
| Build Step | Not required | Required (webpack/vite) |
| State Management | Server state | Complex client state |
| Learning Curve | Low | High |
| SEO | Excellent | Requires SSR |
| Initial Load | Fast | Can be slow |
| Developer Experience | HTML-centric | JSX/Template-centric |
| Mobile Performance | Good | Good |
When to Use HTMX
flowchart TD
A[Building Web App?] --> B{Team Size}
B --> C[Small Team]
B --> D[Large Team]
C --> E{Complexity}
E --> F[Simple CRUD]
F --> G[Use HTMX โ]
E --> H[Highly Interactive]
H --> I[Consider React/Vue]
D --> J{Interactivity}
J --> K[Mostly Static]
K --> L[Use HTMX โ]
J --> M[Complex State]
M --> N[Use React/Vue โ]
O[PExisting Backend] --> P[Use HTMX]
O --> Q[New Full- --> R{Stack]Preference}
R --> S[Simplicity] --> T[Use HTMX]
R --> U[Modern Stack] --> V[Use Next.js/Nuxt]
HTMX Extensions
HTMX has an extension system for additional functionality.
Extensions You Should Know
<!-- Class Tools: Better class manipulation -->
<script src="https://unpkg.com/[email protected]"></script>
<script src="https://unpkg.com/[email protected]/dist/ext/class-tools.js"></script>
<!-- Using class-tools for morphing -->
<button hx-get="/update"
hx-target="#content"
_="on click toggle .loading on closest .container">
Update
</button>
<!-- JSON Schema: Submit as JSON -->
<script src="https://unpkg.com/[email protected]"></script>
<script src="https://unpkg.com/[email protected]/dist/ext/json-enc.js"></script>
<button hx-post="/api"
hx-ext="json-enc"
hx-vals='{"key":"value"}'>
Send JSON
</button>
<!-- Server-Sent Events Extension -->
<script src="https://unpkg.com/[email protected]"></script>
<script src="https://unpkg.com/[email protected]/dist/ext/sse.js"></script>
<!-- WebSocket Extension -->
<script src="https://unpkg.com/[email protected]"></script>
<script src="https://unpkg.com/[email protected]/dist/ext/ws.js"></script>
Best Practices
Do: Return HTML Fragments
# Good: Server returns HTML
@app.route('/users')
def users():
users = User.query.all()
return render_template('users/list.html', users=users)
# Bad: Server returns JSON (defeats HTMX purpose)
@app.route('/users')
def users():
return jsonify([user.to_dict() for user in User.query.all()])
Do: Use Semantic HTML
<!-- Good: Semantic and accessible -->
<button hx-delete="/item/1" hx-confirm="Delete this item?">
Delete
</button>
<!-- Bad: Using div as button -->
<div hx-delete="/item/1">Delete</div>
Don’t Over-Nest
<!-- Avoid deep nesting -->
<div hx-get="/level1">
<div hx-get="/level2">
<div hx-get="/level3">
<!-- Hard to maintain -->
</div>
</div>
</div>
<!-- Better: Flat structure -->
<div id="app">
<div hx-get="/dashboard" hx-target="#app" hx-swap="innerHTML">
</div>
Handle Loading States
<!-- Always show feedback during requests -->
<button hx-get="/data" hx-target="#result" class="btn">
<span class="btn-text">Load Data</span>
<img class="htmx-indicator" src="/spinner.gif">
</button>
<style>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: block; }
.htmx-request .btn-text { opacity: 0.5; }
</style>
Integration with Common Frameworks
Django
# views.py
from django.shortcuts import render
from django.http import JsonResponse
def index(request):
return render(request, 'index.html')
def htmx_endpoint(request):
# Check if it's an HTMX request
if request.headers.get('HX-Request'):
# Return HTML fragment
return render(request, 'partials/content.html', {'item': item})
# Return full page
return render(request, 'full-page.html', {'item': item})
<!-- templates/index.html -->
{% load htmx %}
<script src="https://unpkg.com/[email protected]"></script>
<button hx-get="{% url 'htmx_endpoint' %}" hx-target="#result">
Load
</button>
<div id="result"></div>
Spring Boot
@GetMapping(value = "/users", produces = "text/html")
public String getUsers(Model model, @RequestHeader(value = "HX-Request", required = false) String hxRequest) {
model.addAttribute("users", userService.findAll());
// Return partial for HTMX, full for regular requests
if (hxRequest != null) {
return "fragments/users :: list";
}
return "pages/users";
}
Common Pitfalls
1. Returning JSON Instead of HTML
<!-- Server returns JSON -->
<!-- Problem: HTMX expects HTML -->
<!-- Fix: Return HTML fragment -->
<!-- Bad -->
app.get('/data', (req, res) => {
res.json({ message: 'Hello' });
});
<!-- Good -->
app.get('/data', (req, res) => {
res.send('<span>Hello</span>');
});
2. Missing CSRF Token
<!-- Add CSRF token to all requests -->
<script>
document.body.addEventListener('htmx:configRequest', (event) => {
event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
});
</script>
3. Not Handling Errors
<!-- Add error handling -->
<div hx-get="/api"
hx-target="#result"
hx-on::after-request="if (this.htmx.isError(this)) {
document.getElementById('result').innerHTML = '<span class=error>Failed!</span>';
}">
</div>
External Resources
- HTMX Official Documentation
- HTMX Examples
- HTMX Discord Community
- Hypermedia Systems Book
- Awesome HTMX - Curated Resources
Conclusion
HTMX brings simplicity back to web development by embracing HTML as the primary interface definition language. For applications that don’t require the complexity of modern JavaScript frameworks, HTMX offers a compelling alternative that is faster to build, easier to maintain, and more accessible by default.
The hypermedia-driven approach works exceptionally well for:
- Content-focused websites
- Simple CRUD applications
- Dashboards with moderate interactivity
- Prototypes and MVPs
- Teams with strong backend skills
For highly interactive applications with complex client-side state, consider combining HTMX with a lighter-weight approach or using a traditional SPA framework.
Comments