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.

Leave a Comment

Your email address will not be published. Required fields are marked *