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.
Asset loading
Section titled “Asset loading”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.)
Rendering components
Section titled “Rendering components”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>Styling Django forms
Section titled “Styling Django forms”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" }}Returning HTML fragments
Section titled “Returning HTML fragments”For htmx requests, return only the fragment for the target region.
Use a dedicated template per fragment, or branch on request.htmx.
from django.shortcuts import renderfrom django.template.loader import render_to_stringfrom 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 %}Toasts via HX-Trigger
Section titled “Toasts via HX-Trigger”Use the HX-Trigger response header for client-side notifications.
The toast behavior is the documented listener for hc:toast.
import jsonfrom 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 responseCSRF tips
Section titled “CSRF tips”- Django requires a CSRF token on POST / PUT / PATCH / DELETE. The
<meta name="csrf-token" … data-header="X-CSRFToken">tag inbase.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 setCSRF_COOKIE_HTTPONLY = Truefor 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.
Detecting htmx
Section titled “Detecting htmx”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— setSTATIC_ROOTandSTATICFILES_STORAGEso hashed asset URLs work in production. HC assets are static and benefit from cache busting.form.as_pdefaults — the bundled form rendering does not emithc-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 theHX-Trigger-After-Settleheader instead ofHX-Trigger.