Skip to content

Django

This guide pairs Hypermedia Components with Django + htmx. The optional django-htmx package adds nice request-side helpers; everything below works without it too.

Drop the HC dist files into your static directory and reference them from base.html.

static/
assets/
hc/
hc.css
hc.behaviors.min.js
macros/
index.min.js
htmx.min.js
{# templates/base.html #}
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}My app{% endblock %}</title>
<link rel="stylesheet" href="{% static 'assets/hc/hc.css' %}">
<script defer src="{% static 'assets/htmx.min.js' %}"></script>
<script type="module" src="{% static 'assets/hc/hc.behaviors.min.js' %}"></script>
<script type="module" src="{% static 'assets/hc/macros/index.min.js' %}"></script>
</head>
<body
data-hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% block content %}{% endblock %}
<div class="hc-toast-region"
data-hc-toast-region
role="region"
aria-label="Notifications"></div>
</body>
</html>

The data-hx-headers attribute on <body> makes every htmx request carry the CSRF token Django expects. The token comes from the {% csrf_token %} context processor (enabled by default in new projects).

Reusable template snippets compose well with HC’s part classes. A simple field include:

{# templates/components/_field.html #}
<div class="hc-field"{% if invalid %} data-invalid="true"{% endif %}>
<label class="hc-field__label" for="{{ name }}">{{ label }}</label>
<input
id="{{ name }}"
name="{{ name }}"
value="{{ value|default_if_none:'' }}"
class="hc-input"
{% if invalid %}
aria-invalid="true"
aria-describedby="{{ name }}-error"
{% endif %}>
{% if message %}
<p id="{{ name }}-error" class="hc-field__message">{{ message }}</p>
{% endif %}
</div>

Used from a page:

<form
method="post" action="{% url 'users:create' %}"
data-hx-post="{% url 'users:create' %}"
data-hx-target="this"
data-hx-swap="outerHTML">
{% include 'components/_field.html' with name='email' label='Email' value=form.email.value message=form.email.errors|first invalid=form.email.errors %}
<button class="hc-button" data-variant="primary" type="submit">Create</button>
</form>

To apply hc-input to a Django form widget without writing custom HTML, set the attribute in the form class:

class UserForm(forms.Form):
email = forms.EmailField(
widget=forms.EmailInput(attrs={'class': 'hc-input'})
)

Or, with the {% django-widget-tweaks %} library, in the template:

{% load widget_tweaks %}
{{ form.email|add_class:"hc-input"|attr:"aria-invalid:true" }}

For htmx requests, return only the fragment for the target region. Use a dedicated template per fragment, or branch on request.htmx.

views.py
from django.shortcuts import render
from django.template.loader import render_to_string
from django.http import HttpResponse
def list_items(request):
items = Item.objects.all()
template = 'items/_rows.html' if request.htmx else 'items/list.html'
return render(request, template, {'items': items})
def delete_item(request, item_id):
item = get_object_or_404(Item, pk=item_id)
item.delete()
return HttpResponse(
'',
headers={
'HX-Trigger': json.dumps({
'hc:toast': {
'message': f'Deleted "{item.name}".',
'variant': 'success',
},
}),
},
)

items/_rows.html:

{% for item in items %}
<tr id="item-{{ item.id }}">
<td>{{ item.name }}</td>
<td>
<span class="hc-badge" data-variant="{{ item.status }}">
{{ item.get_status_display }}
</span>
</td>
<td>
<span class="hc-action">
<button
class="hc-button"
data-size="sm"
data-variant="error"
type="button"
data-hc-confirm="Delete {{ item.name }}?"
data-hx-delete="{% url 'items:delete' item.id %}"
data-hx-trigger="hc:confirmed"
data-hx-target="closest tr"
data-hx-swap="outerHTML"
data-hx-disabled-elt="this"
data-hx-indicator="closest .hc-action">
Delete
</button>
<span class="hc-spinner htmx-indicator" aria-hidden="true"></span>
</span>
</td>
</tr>
{% endfor %}

Use the HX-Trigger response header for client-side notifications. The toast behavior is the documented listener for hc:toast.

import json
from django.http import HttpResponse
def save_item(request):
# …mutate state…
return HttpResponse(
rendered_fragment,
headers={
'HX-Trigger': json.dumps({
'hc:toast': {'message': 'Saved.', 'variant': 'success'},
}),
},
)

A small helper avoids repeating the JSON encoding:

def hx_trigger(response, events):
response['HX-Trigger'] = json.dumps(events)
return response
  • Django requires a CSRF token on POST / PUT / PATCH / DELETE. The data-hx-headers attribute in base.html sends it on every htmx request.
  • If your htmx setup uses cookies for state, the CSRF_COOKIE_HTTPONLY = False setting lets a getCsrfToken() JS helper read it directly — useful if you set data-hx-headers per element rather than on <body>.
  • For DELETE and PATCH, Django needs the standard X-CSRFToken header even though there is no form body. data-hx-headers covers this.

The django-htmx middleware adds request.htmx (truthy on htmx requests), request.htmx.boosted, and request headers as attributes. Without the middleware, check the header yourself:

def list_items(request):
is_htmx = request.headers.get('HX-Request') == 'true'
template = 'items/_rows.html' if is_htmx else 'items/list.html'
return render(request, template, {'items': Item.objects.all()})
  • whitenoise — set STATIC_ROOT and STATICFILES_STORAGE so hashed asset URLs work in production. HC assets are static and benefit from cache busting.
  • form.as_p defaults — the bundled form rendering does not emit hc-input. Either render fields by hand or use widget attrs / widget-tweaks to inject the class.
  • HX-Trigger-After-Settle — for events that should fire only after htmx finishes settling DOM changes, use the HX-Trigger-After-Settle header instead of HX-Trigger.