Using HTMX with Django 4.2

django htmx python

HTMX + Django continues to be a compelling stack. With Django 4.2 LTS, there are new patterns and improvements. Here’s the updated guide.

Setup for Django 4.2

pip install django django-htmx
# settings.py
INSTALLED_APPS = [
    ...
    'django_htmx',
]

MIDDLEWARE = [
    ...
    'django_htmx.middleware.HtmxMiddleware',
]
<!-- base.html -->
<!DOCTYPE html>
<html>
<head>
    <script src="https://unpkg.com/htmx.org@1.9.6"></script>
</head>
<body>
    {% block content %}{% endblock %}
</body>
</html>

Core Patterns (Refreshed)

Partial Templates

The key pattern: return full page for normal requests, partial for HTMX:

# views.py
def article_list(request):
    articles = Article.objects.all()
    
    if request.htmx:
        template = 'articles/partials/list.html'
    else:
        template = 'articles/list.html'
    
    return render(request, template, {'articles': articles})

Or use a reusable decorator:

from functools import wraps

def htmx_partial(partial_template):
    def decorator(view_func):
        @wraps(view_func)
        def wrapper(request, *args, **kwargs):
            response = view_func(request, *args, **kwargs)
            if request.htmx and hasattr(response, 'template_name'):
                response.template_name = partial_template
            return response
        return wrapper
    return decorator

@htmx_partial('articles/partials/list.html')
def article_list(request):
    articles = Article.objects.all()
    return render(request, 'articles/list.html', {'articles': articles})

Infinite Scroll

<!-- templates/articles/list.html -->
<div id="article-list">
    {% include 'articles/partials/items.html' %}
</div>
<!-- templates/articles/partials/items.html -->
{% for article in articles %}
    <article class="article">
        <h2>{{ article.title }}</h2>
        <p>{{ article.summary }}</p>
    </article>
{% endfor %}

{% if has_more %}
<div hx-get="{% url 'articles:list' %}?page={{ next_page }}"
     hx-trigger="revealed"
     hx-swap="outerHTML"
     hx-indicator="#loading">
    <span id="loading" class="htmx-indicator">Loading...</span>
</div>
{% endif %}
# views.py
from django.core.paginator import Paginator

def article_list(request):
    page = request.GET.get('page', 1)
    paginator = Paginator(Article.objects.all(), 10)
    page_obj = paginator.get_page(page)
    
    context = {
        'articles': page_obj,
        'has_more': page_obj.has_next(),
        'next_page': page_obj.next_page_number() if page_obj.has_next() else None,
    }
    
    template = 'articles/partials/items.html' if request.htmx else 'articles/list.html'
    return render(request, template, context)

Form Handling

<!-- Modal form with HTMX -->
<button hx-get="{% url 'articles:create' %}"
        hx-target="#modal"
        hx-swap="innerHTML">
    New Article
</button>

<div id="modal"></div>
<!-- templates/articles/partials/form.html -->
<div class="modal-backdrop" onclick="closeModal()">
    <div class="modal-content" onclick="event.stopPropagation()">
        <h2>{% if form.instance.pk %}Edit{% else %}New{% endif %} Article</h2>
        
        <form hx-post="{% if form.instance.pk %}{% url 'articles:update' form.instance.pk %}{% else %}{% url 'articles:create' %}{% endif %}"
              hx-target="#article-list"
              hx-swap="innerHTML">
            {% csrf_token %}
            {{ form.as_p }}
            <button type="submit">Save</button>
            <button type="button" onclick="closeModal()">Cancel</button>
        </form>
    </div>
</div>

<script>
function closeModal() {
    document.getElementById('modal').innerHTML = '';
}
</script>

Form Validation

# views.py
def article_create(request):
    if request.method == 'POST':
        form = ArticleForm(request.POST)
        if form.is_valid():
            article = form.save()
            # Return updated list on success
            return render(request, 'articles/partials/items.html', {
                'articles': Article.objects.all()[:10]
            })
    else:
        form = ArticleForm()
    
    # Return form (with errors on POST with invalid data)
    return render(request, 'articles/partials/form.html', {'form': form})

Real-time Field Validation

<input type="text" name="username"
       hx-post="{% url 'accounts:validate_username' %}"
       hx-trigger="blur changed delay:300ms"
       hx-target="#username-feedback"
       hx-swap="innerHTML">
<span id="username-feedback"></span>
def validate_username(request):
    username = request.POST.get('username', '')
    
    if not username:
        return HttpResponse('')
    
    if len(username) < 3:
        return HttpResponse('<span class="error">Too short</span>')
    
    if User.objects.filter(username=username).exists():
        return HttpResponse('<span class="error">Taken</span>')
    
    return HttpResponse('<span class="success">Available</span>')

Django 4.2 Specific

With Async Views

from django.http import HttpResponse
from asgiref.sync import sync_to_async

async def async_article_list(request):
    articles = await sync_to_async(list)(
        Article.objects.all()[:10]
    )
    
    template = 'articles/partials/items.html' if request.htmx else 'articles/list.html'
    return await sync_to_async(render)(request, template, {'articles': articles})

With Transaction Hooks

from django.db import transaction

def delete_article(request, pk):
    article = get_object_or_404(Article, pk=pk)
    
    with transaction.atomic():
        article.delete()
        # Clear cache after successful delete
        transaction.on_commit(lambda: cache.delete('article_list'))
    
    if request.htmx:
        return HttpResponse('')  # Remove element from DOM
    return redirect('articles:list')

HTMX Extensions

Loading States

<button hx-post="{% url 'slow_action' %}"
        hx-disabled-elt="this"
        hx-indicator="#spinner">
    Save
    <span id="spinner" class="htmx-indicator">⏳</span>
</button>
.htmx-indicator {
    display: none;
}
.htmx-request .htmx-indicator {
    display: inline;
}
.htmx-request.htmx-disabled-elt {
    opacity: 0.5;
    cursor: not-allowed;
}

Confirm Dialogs

<button hx-delete="{% url 'articles:delete' article.id %}"
        hx-confirm="Delete '{{ article.title }}'?"
        hx-target="#article-{{ article.id }}"
        hx-swap="outerHTML swap:200ms">
    Delete
</button>

Out-of-Band Updates

Update multiple elements with one response:

def update_article(request, pk):
    article = get_object_or_404(Article, pk=pk)
    form = ArticleForm(request.POST, instance=article)
    
    if form.is_valid():
        article = form.save()
        
        # Main response
        response = render(request, 'articles/partials/row.html', {'article': article})
        
        # Add out-of-band update for notification area
        notification = render_to_string(
            'partials/notification.html',
            {'message': 'Article updated!'}
        )
        response.content += notification.encode()
        
        return response
<!-- partials/notification.html -->
<div id="notifications" hx-swap-oob="innerHTML">
    <div class="alert success">{{ message }}</div>
</div>

Project Structure

myproject/
├── templates/
│   ├── base.html
│   ├── articles/
│   │   ├── list.html          # Full page
│   │   ├── detail.html        # Full page
│   │   └── partials/
│   │       ├── items.html     # List items only
│   │       ├── row.html       # Single row
│   │       └── form.html      # Form modal
│   └── partials/
│       └── notification.html   # Shared notification

Best Practices

1. Consistent URL Pattern

# urls.py
urlpatterns = [
    path('articles/', views.article_list, name='list'),
    path('articles/<int:pk>/', views.article_detail, name='detail'),
    path('articles/create/', views.article_create, name='create'),
    path('articles/<int:pk>/update/', views.article_update, name='update'),
    path('articles/<int:pk>/delete/', views.article_delete, name='delete'),
]

2. Reusable Partials

<!-- Include with context -->
{% include 'articles/partials/row.html' with article=article show_actions=True %}

3. Progressive Enhancement

<form action="{% url 'articles:create' %}" method="post"
      hx-post="{% url 'articles:create' %}"
      hx-target="#article-list">
    <!-- Works with and without JavaScript -->
</form>

Final Thoughts

HTMX + Django 4.2 is a mature, productive stack. You get:

For many applications, it’s all you need.


Full-stack Python, modern UX.

All posts