Introduction
In an era dominated by complex JavaScript frameworks like React, Vue, and Angular, HTMX stands as a revolutionary alternative. It enables developers to build modern, interactive web applications using simple HTML attributes while keeping all the logic on the server. This guide covers everything from basics to advanced patterns.
Understanding HTMX
What is HTMX?
HTMX is a library that allows you to access modern browser features directly from HTML, rather than using JavaScript. It extends HTML with new attributes that enable AJAX, WebSockets, and Server-Sent Events.
<!-- Traditional approach: JavaScript -->
<button onclick="fetch('/api/data').then(r => r.json()).then(updateDiv)">
Load Data
</button>
<!-- HTMX approach: Pure HTML -->
<button hx-get="/api/data" hx-target="#result">
Load Data
</button>
Why HTMX?
| Aspect | Traditional SPA | HTMX |
|---|---|---|
| JavaScript | Extensive | Minimal |
| Complexity | High | Low |
| Learning Curve | Steep | Gentle |
| Page Load | Heavy initial | Fast |
| SEO | Requires workarounds | Native |
| Server | Thin client | Thick server |
Core Concept: Hypermedia
graph TB
subgraph "Traditional SPA"
Client1[JS Client]
API1[REST API]
DB1[Database]
Client1 --> API1
API1 --> DB1
end
subgraph "HTMX Approach"
Browser[Browser + HTMX]
Server[Server (any language)]
DB2[Database]
Browser --> Server
Server --> DB2
Server -->|HTML| Browser
end
Getting Started
Basic Setup
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTMX Demo</title>
<!-- Load HTMX (from CDN) -->
<script src="https://unpkg.com/[email protected]"></script>
<!-- Optional: HTMX extension for loading indicator -->
<script src="https://unpkg.com/[email protected]/dist/ext/loading-states.js"></script>
</head>
<body>
<!-- Your HTMX-powered content -->
<button hx-get="/message" hx-target="#response">
Click Me
</button>
<div id="response"></div>
</body>
</html>
Using with Backend
# Python/Flask backend example
from flask import Flask, render_template, jsonify
app = Flask(__name__)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/message')
def message():
return '<p>Hello from HTMX!</p>'
# Server returns HTML fragment, not JSON
if __name__ == '__main__':
app.run(debug=True)
// Node/Express backend example
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.send(`
<button hx-get="/message" hx-target="#response">
Click Me
</button>
<div id="response"></div>
`);
});
app.get('/message', (req, res) => {
res.send('<p>Hello from HTMX!</p>');
});
app.listen(3000);
HTMX Attributes
Core Attributes
<!-- hx-get: GET request -->
<a hx-get="/users" hx-target="#user-list">Load Users</a>
<!-- hx-post: POST request -->
<form hx-post="/submit" hx-target="#result">
<input type="text" name="email">
<button type="submit">Submit</button>
</form>
<!-- hx-put: PUT request -->
<button hx-put="/user/1" hx-target="#user">Update User</button>
<!-- hx-delete: DELETE request -->
<button hx-delete="/user/1" hx-target="#user">Delete</button>
<!-- hx-patch: PATCH request -->
<button hx-patch="/user/1" hx-values='{"name": "New Name"}'>Patch</button>
Targeting Elements
<!-- hx-target: Where to display response -->
<button hx-get="/data" hx-target="#div1">Load</button>
<div id="div1"></div>
<!-- hx-swap: How to swap content -->
<!-- afterbegin - Insert at beginning -->
<!-- beforeend - Insert at end (default) -->
<!-- outerHTML - Replace element -->
<!-- beforebegin - Insert before element -->
<!-- afterend - Insert after element -->
<button hx-get="/items" hx-target="#list" hx-swap="afterbegin">
Prepend Item
</button>
<ul id="list"></ul>
<!-- hx-select: Select part of response -->
<div hx-get="/page" hx-select="#content">
Only #content will be swapped in
</div>
Triggering Requests
<!-- hx-trigger: What triggers the request -->
<input type="text" hx-get="/search" hx-trigger="keyup changed delay:500ms">
<!-- Trigger on events -->
<button hx-post="/action" hx-trigger="click">Click</button>
<button hx-post="/action" hx-trigger="mouseenter">Hover</button>
<!-- Trigger on load -->
<div hx-get="/data" hx-trigger="load">Loading...</div>
<!-- Multiple triggers -->
<input type="text" hx-get="/search"
hx-trigger="keyup changed delay:500ms, click">
<!-- Once trigger -->
<button hx-get="/data" hx-trigger="load once">Load Once</button>
Request Headers and Values
<!-- hx-headers: Add custom headers -->
<button hx-get="/api" hx-headers='{"X-Custom": "value"}'>
With Headers
</button>
<!-- hx-vals: Add values to request -->
<button hx-post="/submit" hx-vals='{"extra": "data"}'>
With Values
</button>
<!-- hx-include: Include additional elements -->
<input type="text" name="search" id="search">
<button hx-get="/search" hx-include="#search">
Include Input
</button>
<!-- hx-params: Which params to send -->
<button hx-get="/api" hx-params="*,!secret">Send All Except</button>
Working with Forms
Basic Form Submission
<!-- HTMX form submission -->
<form hx-post="/contact" hx-target="#message" hx-swap="afterbegin">
<label>
Name:
<input type="text" name="name" required>
</label>
<label>
Email:
<input type="email" name="email" required>
</label>
<label>
Message:
<textarea name="message"></textarea>
</label>
<button type="submit">Send</button>
</form>
<div id="message"></div>
# Flask backend for form
@app.route('/contact', methods=['POST'])
def contact():
name = request.form.get('name')
email = request.form.get('email')
message = request.form.get('message')
# Process form...
return f'''
<div class="success">
Thanks {name}! We'll contact you at {email}.
</div>
'''
Form Validation
<!-- Using hx-on for validation feedback -->
<form hx-post="/submit" hx-target="#result">
<input type="email" name="email"
hx-get="/validate/email"
hx-trigger="change"
hx-target="#email-error">
<span id="email-error"></span>
<button type="submit">Submit</button>
</form>
<div id="result"></div>
File Upload
<!-- File upload with HTMX -->
<form hx-post="/upload" hx-target="#upload-result" enctype="multipart/form-data">
<input type="file" name="file" multiple>
<button type="submit">Upload</button>
</form>
<div id="upload-result"></div>
<!-- Progress indicator -->
<div hx-get="/upload/status" hx-trigger="every 500ms" hx-target="#progress">
<progress id="progress" value="0" max="100">0%</progress>
</div>
# Flask file upload
@app.route('/upload', methods=['POST'])
def upload():
files = request.files.getlist('file')
results = []
for file in files:
if file:
filename = secure_filename(file.filename)
file.save(os.path.join('uploads', filename))
results.append(filename)
return f'<ul>{"".join(f"<li>{f}</li>" for f in results)}</ul>'
Advanced Patterns
Infinite Scroll
<!-- Initial content -->
<div id="articles">
{% for article in articles %}
<article>
<h2>{{ article.title }}</h2>
<p>{{ article.summary }}</p>
</article>
{% endfor %}
</div>
<!-- Load more button -->
<button hx-get="/more-articles?page=2"
hx-target="#articles"
hx-swap="beforeend"
hx-select="article">
Load More
</button>
# Flask - Load more
@app.route('/more-articles')
def more_articles():
page = int(request.args.get('page', 2))
articles = get_articles(page=page, per_page=10)
return render_template('articles_partial.html', articles=articles)
Search with Debounce
<!-- Live search -->
<div class="search-container">
<input type="search"
name="q"
placeholder="Search..."
hx-get="/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#search-results"
hx-indicator="#loading">
<div id="loading" class="htmx-indicator">
Searching...
</div>
</div>
<div id="search-results"></div>
/* Loading indicator styles */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request.htmx-indicator {
display: inline;
}
Tabs
<!-- HTMX Tabs -->
<div class="tabs">
<button class="tab active"
hx-get="/tab/content"
hx-vals='{"tab": "about"}'
hx-target="#tab-content">
About
</button>
<button class="tab"
hx-get="/tab/content"
hx-vals='{"tab": "features"}'
hx-target="#tab-content">
Features
</button>
<button class="tab"
hx-get="/tab/content"
hx-vals='{"tab": "contact"}'
hx-target="#tab-content">
Contact
</button>
</div>
<div id="tab-content">
<!-- Tab content loads here -->
</div>
Modal Dialogs
<!-- Open modal -->
<button hx-get="/modal/content"
hx-target="body"
hx-swap="beforeend">
Open Modal
</button>
<!-- Modal template -->
<div id="modal" class="modal" hx-get="/modal/form" hx-trigger="load">
<div class="modal-content">
<span class="close" hx-on="click: document.getElementById('modal').remove()">
×
</span>
<div id="modal-body">
Loading...
</div>
</div>
</div>
Delete with Confirmation
<!-- Delete item -->
<tr id="item-1">
<td>Product 1</td>
<td>$99</td>
<td>
<button hx-delete="/items/1"
hx-target="#item-1"
hx-swap="outerHTML"
hx-confirm="Are you sure?">
Delete
</button>
</td>
</tr>
HTMX Extensions
Loading States
<!-- Add loading class to parent -->
<button hx-get="/data"
hx-indicator="#indicator"
hx-target="#result">
Load
</button>
<img id="indicator" class="htmx-indicator" src="/spinner.gif">
<div id="result"></div>
Class Swapping
<!-- Swap classes on request -->
<button hx-get="/action"
hx-swap-oob="outerHTML:#status">
Process
</button>
<span id="status" class="btn">Pending</span>
<!-- After request, server returns: -->
<span id="status" class="btn success">Complete</span>
WebSockets
<!-- Real-time updates via WebSocket -->
<div hx-ws="connect:/ws">
<div hx-ws="swap:message">
<!-- Messages appear here -->
</div>
</div>
<!-- Send message -->
<form hx-ws="send">
<input type="text" name="message">
<button type="submit">Send</button>
</form>
Server-Sent Events
<!-- SSE for real-time updates -->
<div hx-sse="connect:/events">
<div hx-sse="swap:message">
<!-- Server events appear here -->
</div>
</div>
# Flask SSE
from flask import Response
import time
@app.route('/events')
def events():
def generate():
while True:
data = get_latest_data() # Your data source
yield f"data: {data}\n\n"
time.sleep(5)
return Response(generate(), mimetype='text/event-stream')
HTMX with Python
Django Integration
# Django views.py
from django.shortcuts import render
from django.http import JsonResponse
from django.template.loader import render_to_string
def index(request):
return render(request, 'index.html')
def users_list(request):
users = User.objects.all()
return render(request, 'users_partial.html', {'users': users})
def create_user(request):
if request.method == 'POST':
form = UserForm(request.POST)
if form.is_valid():
user = form.save()
return render(request, 'user_row.html', {'user': user})
return HttpResponseBadRequest()
<!-- Django template with HTMX -->
{% load static %}
<link rel="stylesheet" href="{% static 'css/htmx.css' %}">
<script src="https://unpkg.com/[email protected]"></script>
<div hx-get="{% url 'users_list' %}" hx-trigger="load">
Loading users...
</div>
<table>
<tbody id="users-table">
{% for user in users %}
{% include 'user_row.html' %}
{% endfor %}
</tbody>
</table>
<button hx-post="{% url 'create_user' %}"
hx-target="#users-table"
hx-swap="beforeend">
Add User
</button>
FastAPI Integration
# FastAPI + HTMX
from fastapi import FastAPI, Request, Form
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
app = FastAPI()
templates = Jinja2Templates(directory="templates")
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.post("/add-task", response_class=HTMLResponse)
async def add_task(request: Request, task: str = Form(...)):
# Save to database
new_task = Task.objects.create(text=task)
return templates.TemplateResponse("task_item.html", {
"request": request,
"task": new_task
})
@app.get("/tasks", response_class=HTMLResponse)
async def get_tasks(request: Request):
tasks = Task.objects.all()
return templates.TemplateResponse("task_list.html", {
"request": request,
"tasks": tasks
})
HTMX with Node.js
Express Integration
// Express + HTMX
const express = require('express');
const { renderFile } = require('eta'); // Template engine
const path = require('path');
const app = express();
app.set('view engine', 'eta');
app.set('views', path.join(__dirname, 'views'));
// Store tasks in memory
let tasks = [];
app.get('/', (req, res) => {
res.render('index.eta', { tasks });
});
app.post('/add-task', express.urlencoded({ extended: true }), (req, res) => {
const { task } = req.body;
const newTask = { id: Date.now(), text: task };
tasks.push(newTask);
res.render('task-item.eta', { task: newTask });
});
app.delete('/task/:id', (req, res) => {
const id = parseInt(req.params.id);
tasks = tasks.filter(t => t.id !== id);
res.send('');
});
app.listen(3000, () => console.log('Server running on port 3000'));
<!-- index.eta -->
<!DOCTYPE html>
<html>
<head>
<title>HTMX Tasks</title>
<script src="https://unpkg.com/[email protected]"></script>
</head>
<body>
<h1>Tasks</h1>
<form hx-post="/add-task" hx-target="#task-list" hx-swap="beforeend">
<input type="text" name="task" placeholder="New task" required>
<button type="submit">Add</button>
</form>
<ul id="task-list">
<%~ it.tasks.map(t => { %>
<li>
<%= t.text %>
<button hx-delete="/task/<%= t.id %>"
hx-target="closest li"
hx-swap="outerHTML">
Delete
</button>
</li>
<% }) %>
</ul>
</body>
</html>
Styling HTMX
CSS for Transitions
/* Fade in new content */
.htmx-swapping {
opacity: 0;
transition: opacity 500ms ease-out;
}
.htmx-settling {
opacity: 1;
transition: opacity 500ms ease-in;
}
/* Loading indicator */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
/* Button states */
.htmx-request.btn {
opacity: 0.7;
pointer-events: none;
}
Tailwind Integration
<!-- Tailwind + HTMX -->
<button hx-get="/data"
hx-target="#result"
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition">
Load Data
</button>
<div id="result"></div>
<!-- Loading state -->
<div hx-get="/data"
hx-indicator="#loading"
hx-target="#result">
<span id="loading" class="htmx-indicator">
Loading...
</span>
</div>
Best Practices
1. Return HTML Fragments
# โ
Good: Return HTML fragment
@app.route('/users')
def users():
users = User.objects.all()
return render_template('users_list.html', users=users)
# โ Bad: Return JSON (HTMX expects HTML)
@app.route('/users')
def users():
return jsonify(users=[u.to_dict() for u in User.objects.all()])
2. Use OOB Swaps for Multiple Updates
<!-- Update multiple elements from one request -->
<button hx-post="/like"
hx-target="#post"
hx-swap-oob="outerHTML:#like-count">
Like
</button>
<div id="post">Post content...</div>
<span id="like-count">42 likes</span>
3. Handle Errors Gracefully
@app.route('/action')
def action():
try:
result = do_something()
return render_template('success.html', result=result)
except Exception as e:
# Return error message
return f'''
<div class="error" hx-swap-oob="outerHTML:#error-msg">
Error: {str(e)}
</div>
''', 400
4. Use Progressive Enhancement
<!-- Works without JS -->
<form action="/search" method="GET">
<input type="search" name="q">
<button type="submit">Search</button>
</form>
<!-- Enhanced with HTMX -->
<form hx-get="/search"
hx-target="#results"
hx-trigger="submit"
hx-push-url="true">
<input type="search" name="q">
<button type="submit">Search</button>
</form>
<div id="results"></div>
HTMX vs React Comparison
| Feature | React | HTMX |
|---|---|---|
| JavaScript Required | Yes | Minimal |
| State Management | Complex (Redux, etc.) | Server-side |
| Initial Load | Heavy bundle | Light HTML |
| SEO | Requires SSR | Native |
| Learning Curve | Steep | Gentle |
| Code Amount | Thousands of lines | Hundreds |
| Bundle Size | 100KB+ | 14KB |
Conclusion
HTMX represents a paradigm shift back to simplicity in web development:
- Simple: Just HTML with new attributes
- Fast: No JavaScript bundles to download
- SEO-Friendly: Server-rendered HTML
- Progressive: Works without JS, enhanced with it
- Backend Agnostic: Works with any server language
Perfect for content sites, internal tools, and developers who want simplicity over complexity.
Comments