HTMX with Django
Django is a Python web framework that follows the "batteries included" philosophy — it ships with an ORM, an admin panel, authentication, form handling, and a templating engine. HTMX pairs exceptionally well with Django because Django's server-side rendering model aligns perfectly with HTMX's expectation of HTML fragment responses. This topic covers the complete integration pattern.
Project Setup
# Create a virtual environment and install Django python -m venv venv source venv/bin/activate # Windows: venv\Scripts\activate pip install django django-htmx # Create a Django project and app django-admin startproject mysite cd mysite python manage.py startapp tasks
The django-htmx package provides a middleware that makes HTMX header detection clean and Pythonic.
Settings Configuration
# mysite/settings.py
INSTALLED_APPS = [
...
'django_htmx',
'tasks',
]
MIDDLEWARE = [
...
'django_htmx.middleware.HtmxMiddleware',
]
Base Template With HTMX
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% block title %}My App{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
{% csrf_token %}
</head>
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% block content %}{% endblock %}
</body>
</html>
The hx-headers attribute on <body> automatically adds the CSRF token to every HTMX request site-wide. Django's CSRF middleware validates it on every state-changing request.
Model
# tasks/models.py
from django.db import models
class Task(models.Model):
title = models.CharField(max_length=200)
done = models.BooleanField(default=False)
created = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title
python manage.py makemigrations tasks python manage.py migrate
Views With HTMX Detection
# tasks/views.py
from django.shortcuts import render, get_object_or_404
from django.views.decorators.http import require_http_methods
from .models import Task
def task_list(request):
tasks = Task.objects.all().order_by('-created')
if request.htmx:
return render(request, 'tasks/partials/task_list.html', {'tasks': tasks})
return render(request, 'tasks/index.html', {'tasks': tasks})
@require_http_methods(['POST'])
def add_task(request):
title = request.POST.get('title', '').strip()
if title:
task = Task.objects.create(title=title)
return render(request, 'tasks/partials/task_item.html', {'task': task})
return render(request, 'tasks/partials/error.html', {'msg': 'Title required'})
@require_http_methods(['DELETE'])
def delete_task(request, pk):
task = get_object_or_404(Task, pk=pk)
task.delete()
return HttpResponse('') # Empty response — element removed by outerHTML swap
request.htmx is provided by django-htmx middleware. It is True when the request includes the HX-Request: true header.
URL Configuration
# tasks/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('', views.task_list, name='task-list'),
path('tasks/add/', views.add_task, name='add-task'),
path('tasks/<int:pk>/delete/', views.delete_task, name='delete-task'),
]
Templates
Main Page Template
<!-- templates/tasks/index.html -->
{% extends 'base.html' %}
{% block content %}
<h2>Task Manager</h2>
<form hx-post="{% url 'add-task' %}"
hx-target="#task-list"
hx-swap="afterbegin"
hx-on:htmx:after-request="this.reset()">
{% csrf_token %}
<input type="text" name="title" placeholder="New task..." required>
<button type="submit">Add Task</button>
</form>
<ul id="task-list">
{% for task in tasks %}
{% include 'tasks/partials/task_item.html' %}
{% endfor %}
</ul>
{% endblock %}
Task Item Partial
<!-- templates/tasks/partials/task_item.html -->
<li id="task-{{ task.pk }}">
{{ task.title }}
<button
hx-delete="{% url 'delete-task' task.pk %}"
hx-target="#task-{{ task.pk }}"
hx-swap="outerHTML"
hx-confirm="Delete '{{ task.title }}'?">
Delete
</button>
</li>
Full flow:
User types "Buy milk" → clicks Add Task
↓
HTMX POST /tasks/add/ + CSRF token
↓
Django creates Task(title="Buy milk") in DB
Returns task_item.html partial
↓
HTMX prepends <li> to #task-list
Form resets (hx-on resets after request)
↓
User sees "Buy milk" at the top of the list
Using Django Class-Based Views
from django.views import View
from django.http import HttpResponse
class TaskDeleteView(View):
def delete(self, request, pk):
task = get_object_or_404(Task, pk=pk)
task.delete()
return HttpResponse('')
Django Form Integration
Django forms work naturally with HTMX for validation:
# forms.py
from django import forms
class TaskForm(forms.Form):
title = forms.CharField(max_length=200, required=True)
# views.py
def add_task(request):
form = TaskForm(request.POST or None)
if request.method == 'POST':
if form.is_valid():
task = Task.objects.create(title=form.cleaned_data['title'])
return render(request, 'tasks/partials/task_item.html', {'task': task})
# Return form with errors for HTMX to swap in
return render(request, 'tasks/partials/add_form.html', {'form': form}, status=422)
return render(request, 'tasks/partials/add_form.html', {'form': form})
Key Takeaway
Django and HTMX integrate naturally. Install django-htmx for clean HTMX header detection via request.htmx. Use the hx-headers attribute on the body to globally attach the Django CSRF token. Write views that return full templates for direct visits and partial templates for HTMX requests. Django's template system, ORM, and form validation all work without modification alongside HTMX — you just change what the view returns based on the request type.
