HTMX + Django: Full Stack Python in 2022
django python htmx
HTMX lets you build modern, interactive UIs without writing JavaScript. Combined with Django, you get a full-stack Python experience. Here’s how.
What is HTMX
HTMX extends HTML with attributes that make AJAX requests and update the DOM:
<button hx-post="/clicked" hx-swap="outerHTML">
Click Me
</button>
When clicked:
- Makes POST request to
/clicked - Replaces the button with the response HTML
No JavaScript. Just HTML attributes.
Setup
pip install django-htmx
# settings.py
INSTALLED_APPS = [
...
'django_htmx',
]
MIDDLEWARE = [
...
'django_htmx.middleware.HtmxMiddleware',
]
<!-- base.html -->
<head>
<script src="https://unpkg.com/htmx.org@1.8.4"></script>
</head>
Core Patterns
Click to Load
<!-- templates/todos/list.html -->
<div id="todo-list">
{% for todo in todos %}
<div class="todo">{{ todo.title }}</div>
{% endfor %}
{% if has_more %}
<button hx-get="{% url 'todos:list' %}?page={{ next_page }}"
hx-target="#todo-list"
hx-swap="beforeend">
Load More
</button>
{% endif %}
</div>
# views.py
def todo_list(request):
page = int(request.GET.get('page', 1))
todos = Todo.objects.all()[(page-1)*10:page*10]
template = 'todos/partials/items.html' if request.htmx else 'todos/list.html'
return render(request, template, {
'todos': todos,
'has_more': Todo.objects.count() > page * 10,
'next_page': page + 1
})
Inline Editing
<!-- templates/todos/todo_row.html -->
<div id="todo-{{ todo.id }}" class="todo-row">
<span>{{ todo.title }}</span>
<button hx-get="{% url 'todos:edit' todo.id %}"
hx-target="#todo-{{ todo.id }}"
hx-swap="outerHTML">
Edit
</button>
</div>
<!-- templates/todos/todo_edit.html -->
<form id="todo-{{ todo.id }}"
hx-put="{% url 'todos:update' todo.id %}"
hx-target="this"
hx-swap="outerHTML">
<input type="text" name="title" value="{{ todo.title }}">
<button type="submit">Save</button>
<button hx-get="{% url 'todos:detail' todo.id %}"
hx-target="#todo-{{ todo.id }}"
hx-swap="outerHTML">
Cancel
</button>
</form>
# views.py
def todo_update(request, pk):
todo = get_object_or_404(Todo, pk=pk)
todo.title = request.POST.get('title')
todo.save()
return render(request, 'todos/todo_row.html', {'todo': todo})
Delete with Confirmation
<button hx-delete="{% url 'todos:delete' todo.id %}"
hx-confirm="Are you sure?"
hx-target="#todo-{{ todo.id }}"
hx-swap="outerHTML swap:1s">
Delete
</button>
Search with Debounce
<input type="search"
name="q"
hx-get="{% url 'search' %}"
hx-trigger="keyup changed delay:300ms"
hx-target="#results"
placeholder="Search...">
<div id="results">
{% include 'search/results.html' %}
</div>
# views.py
def search(request):
q = request.GET.get('q', '')
results = Item.objects.filter(name__icontains=q)[:20] if q else []
return render(request, 'search/results.html', {'results': results})
Form Validation
<!-- Real-time username validation -->
<input type="text" name="username"
hx-post="{% url 'validate_username' %}"
hx-trigger="blur changed"
hx-target="#username-error">
<span id="username-error"></span>
# views.py
def validate_username(request):
username = request.POST.get('username', '')
if not username:
return HttpResponse('<span class="error">Username required</span>')
if User.objects.filter(username=username).exists():
return HttpResponse('<span class="error">Username taken</span>')
return HttpResponse('<span class="success">Username available</span>')
Advanced Patterns
Polling
<div hx-get="{% url 'notifications' %}"
hx-trigger="every 30s"
hx-target="#notification-count">
<span id="notification-count">{{ count }}</span>
</div>
Progress Indicator
<button hx-post="{% url 'long_task' %}"
hx-indicator="#spinner">
Start Task
</button>
<div id="spinner" class="htmx-indicator">
<img src="/static/spinner.gif">
</div>
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
Modal Dialog
<button hx-get="{% url 'modal' %}"
hx-target="body"
hx-swap="beforeend">
Open Modal
</button>
<!-- modal.html -->
<div id="modal" class="modal">
<div class="modal-content">
<h2>Modal Title</h2>
<form hx-post="{% url 'submit' %}"
hx-target="#modal"
hx-swap="outerHTML">
<!-- form content -->
<button type="submit">Submit</button>
</form>
<button onclick="document.getElementById('modal').remove()">
Close
</button>
</div>
</div>
Infinite Scroll
<div id="items">
{% for item in items %}
{% include 'item.html' %}
{% endfor %}
<div hx-get="{% url 'items' %}?page={{ next_page }}"
hx-trigger="revealed"
hx-swap="outerHTML">
<!-- Loading trigger -->
</div>
</div>
Django HTMX Integration
Detecting HTMX Requests
# views.py
def my_view(request):
if request.htmx:
# HTMX request - return partial
return render(request, 'partial.html', context)
else:
# Full page request
return render(request, 'full.html', context)
Trigger Events
from django_htmx.http import trigger_client_event
def add_item(request):
item = Item.objects.create(...)
response = render(request, 'item.html', {'item': item})
return trigger_client_event(response, 'itemAdded')
<div hx-get="{% url 'items' %}" hx-trigger="itemAdded from:body">
<!-- Refreshes when itemAdded event fires -->
</div>
Redirects
from django_htmx.http import HttpResponseClientRedirect
def logout(request):
auth_logout(request)
return HttpResponseClientRedirect('/')
When to Use HTMX
Good For
- Django monoliths
- Server-rendered apps needing interactivity
- Teams that prefer Python over JavaScript
- Progressive enhancement
- CRUD applications
Less Good For
- Complex client-side state management
- Offline-first applications
- Heavy real-time collaboration (use WebSockets)
- Teams already invested in React/Vue
Final Thoughts
HTMX + Django is surprisingly productive. Most “single-page app” interactions can be achieved with:
- Django views (Python)
- Django templates (HTML)
- HTMX attributes (no JavaScript)
It’s not for everything, but for many apps, it’s enough—and simpler.
The best code is the code you don’t have to write.