HTMX with Django: SPA Feel without JS Fatigue
The pendulum is swinging back. After years of React/Vue/Angular, developers are rediscovering server-rendered HTML. HTMX makes it interactive without the JavaScript complexity.
What is HTMX?
HTMX extends HTML with attributes that enable:
- AJAX requests from any element
- Partial page updates
- WebSocket connections
- Server-sent events
No JavaScript required (beyond the htmx.js library).
<!-- Click button, fetch HTML, insert into #result -->
<button hx-get="/api/data" hx-target="#result">
Load Data
</button>
<div id="result"></div>
Setup with Django
Installation
<!-- In base.html -->
<script src="https://unpkg.com/htmx.org@1.9.0"></script>
Or via npm/CDN/vendor.
Basic Pattern
# views.py
from django.shortcuts import render
def index(request):
return render(request, 'index.html')
def load_items(request):
items = Item.objects.all()[:10]
return render(request, 'partials/items.html', {'items': items})
<!-- templates/index.html -->
<button hx-get="{% url 'load_items' %}" hx-target="#items-container">
Load Items
</button>
<div id="items-container"></div>
<!-- templates/partials/items.html -->
{% for item in items %}
<div class="item">{{ item.name }}</div>
{% endfor %}
Common Patterns
Infinite Scroll
<!-- templates/partials/items.html -->
{% for item in items %}
<div class="item">{{ item.name }}</div>
{% endfor %}
{% if has_more %}
<div hx-get="{% url 'load_items' %}?page={{ next_page }}"
hx-trigger="revealed"
hx-swap="afterend">
<!-- Loading indicator -->
</div>
{% endif %}
# views.py
def load_items(request):
page = int(request.GET.get('page', 1))
items = Item.objects.all()[(page-1)*10:page*10]
has_more = Item.objects.count() > page * 10
return render(request, 'partials/items.html', {
'items': items,
'has_more': has_more,
'next_page': page + 1
})
Inline Editing
<!-- Display mode -->
<div id="item-{{ item.id }}">
<span>{{ item.name }}</span>
<button hx-get="{% url 'item_edit' item.id %}"
hx-target="#item-{{ item.id }}"
hx-swap="outerHTML">
Edit
</button>
</div>
<!-- Edit mode (returned from server) -->
<form hx-put="{% url 'item_update' item.id %}"
hx-target="#item-{{ item.id }}"
hx-swap="outerHTML">
<input name="name" value="{{ item.name }}">
<button type="submit">Save</button>
<button hx-get="{% url 'item_view' item.id %}"
hx-target="#item-{{ item.id }}"
hx-swap="outerHTML">Cancel</button>
</form>
Search as You Type
<input type="search"
name="q"
hx-get="{% url 'search' %}"
hx-trigger="keyup changed delay:300ms"
hx-target="#search-results">
<div id="search-results"></div>
Form Submission
<form hx-post="{% url 'create_item' %}"
hx-target="#items-list"
hx-swap="beforeend">
{% csrf_token %}
<input name="name" required>
<button type="submit">Add</button>
</form>
<div id="items-list">
{% for item in items %}
<div>{{ item.name }}</div>
{% endfor %}
</div>
# views.py
def create_item(request):
if request.method == 'POST':
item = Item.objects.create(name=request.POST['name'])
return render(request, 'partials/item_row.html', {'item': item})
HTMX Attributes
| Attribute | Purpose |
|---|---|
hx-get, hx-post, hx-put, hx-delete | HTTP methods |
hx-target | Where to put response |
hx-swap | How to insert (innerHTML, outerHTML, beforeend, etc.) |
hx-trigger | What triggers request |
hx-indicator | Loading indicator |
hx-confirm | Confirmation dialog |
Django Specific Tips
CSRF Handling
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
Or per-form with {% csrf_token %}.
Detecting HTMX Requests
def my_view(request):
if request.headers.get('HX-Request'):
# Partial response
return render(request, 'partials/content.html')
else:
# Full page
return render(request, 'full_page.html')
django-htmx Package
pip install django-htmx
# views.py
def my_view(request):
if request.htmx:
if request.htmx.boosted:
# Boosted navigation
pass
return render(request, 'partials/content.html')
Adds request.htmx with useful properties.
Messages Integration
<!-- In base template -->
<div id="messages" hx-swap-oob="true">
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
</div>
Out-of-band swaps update messages on any HTMX response.
vs JavaScript Frameworks
| Aspect | HTMX | React/Vue |
|---|---|---|
| Learning curve | Low | Higher |
| Bundle size | 14KB | 100KB+ |
| Server coupling | Tight | Loose |
| Real-time | WebSocket support | Full control |
| Complex state | Limited | Excellent |
| SEO | Great (server-rendered) | Needs SSR |
When to Use HTMX
✅ Traditional web apps with interactivity ✅ Progressive enhancement ✅ Teams with Django/Rails expertise ✅ Content-heavy sites ✅ Admin panels and dashboards
❌ Highly interactive apps (Figma, Google Docs) ❌ Offline-first applications ❌ Complex client-side state management
Final Thoughts
HTMX isn’t replacing React. It’s an alternative for different use cases.
If your app is basically CRUD with some interactivity, HTMX + Django is simpler than a full SPA. You keep your Django templates, your Django views, your Django ecosystem.
Try it on your next internal tool. You might be surprised how far it gets you.
Sometimes the old ways are the better ways.