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>
{# Read by the auto-init installCsrfHeader() behavior on every htmx
request; data-header renames the header to what Django expects. #}
<meta name="csrf-token" content="{{ csrf_token }}" data-header="X-CSRFToken">
</head>
<body>
{% block content %}{% endblock %}
<div class="hc-toast-region"
data-hc-toast-region
role="region"
aria-label="Notifications"></div>
</body>
</html>

The <meta name="csrf-token"> tag is the kit’s blessed CSRF convention: installCsrfHeader() (in the auto-init behaviors bundle) reads it at request time and adds the header to every htmx request — data-header renames it to Django’s X-CSRFToken. No JavaScript to write, and a rotated token swapped into the meta is picked up automatically. (data-hx-headers on <body> also works if you prefer htmx-native wiring; see htmx → CSRF for the trade-offs.)

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 <meta name="csrf-token" … data-header="X-CSRFToken"> tag in base.html (see the layout above) is all you need — installCsrfHeader() adds the header to every htmx request, including DELETE and PATCH, which have no form body to carry the token.
  • The meta tag reads {{ csrf_token }} server-side, so no JavaScript ever needs to read the CSRF cookie — you can even set CSRF_COOKIE_HTTPONLY = True for defense in depth.
  • Rotating tokens? Django rotates the CSRF token on login. Render the meta tag from the template (as above) rather than caching the layout, and the fresh token travels with the next full page load.

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.