Django-Admin.ru

HTMX и Django: интерактивность без SPA

Как подключить HTMX к Django: атрибуты hx-get, hx-post и hx-swap, CSRF, пакет django-htmx, партиалы Django 6, живой поиск, бесконечная лента и формы.

Редакция Django-Admin.ru
· 24 мин чтения

Содержание · 12 разделов
  1. Как работает HTMX
  2. Какую версию HTMX выбрать
  3. Подключение HTMX к Django
  4. Через CDN
  5. Из статических файлов
  6. Через пакет django-htmx
  7. Основные атрибуты HTMX
  8. hx-target: куда вставить ответ
  9. hx-swap: как вставить ответ
  10. hx-trigger: когда отправить запрос
  11. CSRF-защита в запросах HTMX
  12. Пакет django-htmx: request.htmx и ответы
  13. Объект request.htmx
  14. HttpResponseClientRedirect и другие ответы
  15. Частичные шаблоны: страница и фрагмент из одного view
  16. Отдельные шаблоны через include
  17. Партиалы Django 6.0: partialdef
  18. Заголовок Vary
  19. Рабочий пример: каталог книг
  20. Живой поиск
  21. Бесконечная прокрутка
  22. Inline-редактирование и удаление
  23. Отправка формы с валидацией
  24. Проверка через тестовый клиент Django
  25. Частые ошибки
  26. Когда HTMX не подходит
  27. Частые вопросы
  28. Обязательно ли устанавливать django-htmx?
  29. Как показать индикатор загрузки?
  30. Как обновить несколько частей страницы одним ответом?
  31. Как передать в запрос дополнительные данные?
  32. Как перейти на htmx 4?
  33. Итог

HTMX — небольшая JavaScript-библиотека (около 16 КБ в gzip), которая позволяет любому HTML-элементу отправить запрос на сервер и заменить часть страницы полученным HTML. Связка htmx + Django работает так: Django-view рендерит фрагмент шаблона, а HTMX вставляет его в нужное место без перезагрузки страницы. Отдельный фронтенд на React или Vue, JSON API и сборщик не нужны — интерактивность описывается атрибутами прямо в шаблоне:

<button hx-get="{% url 'hello' %}" hx-target="#greeting">Поздороваться</button>
<div id="greeting"></div>
from django.http import HttpResponse

def hello(request):
    return HttpResponse("<p>Привет от Django!</p>")

По клику HTMX отправит GET-запрос и вставит ответ внутрь #greeting. Ниже — подключение HTMX, главные атрибуты, CSRF, пакет django-htmx, партиалы Django 6.0 и рабочий пример с живым поиском, бесконечной прокруткой, inline-редактированием и формой с валидацией. Примеры проверены на Django 6.1, django-htmx 1.29 и htmx 2.

Как работает HTMX

Обычный сайт на Django отдаёт страницу целиком: ссылка или форма — запрос — новая страница. HTMX снимает три ограничения HTML: запрос может отправить любой элемент, а не только ссылка и форма; запускать его может любое событие (ввод текста, прокрутка, таймер); ответ заменяет часть страницы, а не всю.

Цикл одного взаимодействия:

  1. Пользователь кликает, вводит текст или прокручивает страницу.
  2. HTMX отправляет AJAX-запрос с заголовком HX-Request: true.
  3. Django-view обрабатывает его как обычный запрос и возвращает HTML-фрагмент.
  4. HTMX вставляет фрагмент в элемент из hx-target способом из hx-swap.

Состояние живёт на сервере, валидация остаётся в формах Django, шаблоны — единственное место, где описан внешний вид. Такой подход называют «HTML по сети» (HTML over the wire): сервер отдаёт готовую разметку вместо JSON.

Какую версию HTMX выбрать

В августе 2026 года вышел htmx 4.0, но в npm он помечен тегом next, а latest остаётся у htmx 2 (сейчас 2.0.11). Разработчики обещают сделать 4.x основной в 2027 году и поддерживать 2.x без ограничения срока. Версии 3 нет — проект перешёл с 2 сразу на 4.

Что изменилосьhtmx 2htmx 4
Наследование атрибутовНеявное: атрибут родителя действует на потомковТолько явное: hx-headers:inherited, hx-confirm:inherited
Ответы 4xx и 5xxНе вставляются в страницуВставляются; не вставляются только 204 и 304
Имена событийhtmx:afterRequest, htmx:beforeSwaphtmx:after:request, htmx:before:swap
Блокировка кнопки на время запросаhx-disabled-elthx-disable (прежний hx-disable стал hx-ignore)
История браузераСнимки страниц в localStorageПовторный запрос страницы при возврате назад
Внутренний транспортXMLHttpRequestfetch()

Для нового проекта подойдут обе версии. htmx 2 используется по умолчанию в django-htmx и почти во всех руководствах, поэтому примеры ниже написаны для него. Что поменять при переходе на 4, — в разделе частых вопросов в конце статьи.

Подключение HTMX к Django

HTMX — один JS-файл без зависимостей. Подключите его в базовом шаблоне, от которого наследуются остальные страницы.

Через CDN

Самый быстрый способ для прототипа — тег script с указанием точной версии и хешем целостности:

<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js"
        integrity="sha384-2OatzQy1H+Zd/IIrjr1TcuDGqLXeHhbooAyJY1KdQMKnr4LZ22k31GBLdYKHmVjg"
        crossorigin="anonymous"></script>

Не подключайте адрес без версии вида unpkg.com/htmx.org: он ведёт на последнюю версию, и обновление до htmx 4 однажды сломает сайт.

Из статических файлов

Для продакшена надёжнее хранить файл в проекте: сайт не зависит от CDN, а Content Security Policy (CSP) не нужно открывать для внешнего домена. Скачайте файл в папку статики:

mkdir -p static/js
curl -L -o static/js/htmx.min.js https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js
New-Item -ItemType Directory -Force static\js
Invoke-WebRequest https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js -OutFile static\js\htmx.min.js

И подключите через тег static:

{% load static %}
<script src="{% static 'js/htmx.min.js' %}" defer></script>

Через пакет django-htmx

django-htmx — пакет Адама Джонсона с middleware, классами ответов и копией htmx внутри. Версия 1.29 поддерживает Django 5.2, 6.0 и 6.1 и Python 3.10+. Установите его в виртуальное окружение проекта:

py -m pip install django-htmx
python3 -m pip install django-htmx

Добавьте приложение и middleware в settings.py:

INSTALLED_APPS = [
    # ...
    "django_htmx",
]

MIDDLEWARE = [
    # ...
    "django_htmx.middleware.HtmxMiddleware",
]

Теперь скрипт подключается тегом htmx_script:

{% load django_htmx %}
<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <title>{% block title %}Книги{% endblock %}</title>
  {% htmx_script %}
</head>
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
  {% block content %}{% endblock %}
</body>
</html>

Тег выводит встроенную в пакет htmx 2 (в django-htmx 1.29 — версия 2.0.10) с атрибутом defer. При DEBUG = True он добавляет отладочный скрипт: если сервер ответил ошибкой 400, 403, 404 или 500, скрипт покажет страницу ошибки Django на весь экран. Без него htmx 2 молча проигнорирует такой ответ. Параметр version=4 подключает htmx 4, minified=False — неминифицированную сборку для отладки:

<script src="/static/django_htmx/htmx-2.min.js" defer></script><script src="/static/django_htmx/django-htmx.js" data-debug="True" defer></script>

Атрибут hx-headers на body нужен для CSRF — о нём отдельный раздел ниже.

Основные атрибуты HTMX

АтрибутЧто делает
hx-get, hx-postОтправить GET или POST на указанный URL
hx-put, hx-patch, hx-deleteЗапросы с другими HTTP-методами
hx-targetКуда вставить ответ (CSS-селектор или относительный селектор)
hx-swapКак вставить ответ: заменить содержимое, элемент целиком, добавить в конец
hx-triggerКакое событие запускает запрос
hx-push-urlЗаписать URL запроса в адресную строку и историю браузера
hx-indicatorЭлемент-индикатор загрузки
hx-confirmСпросить подтверждение перед запросом
hx-syncЧто делать, если новый запрос стартует до окончания предыдущего
hx-disabled-eltЗаблокировать кнопку на время запроса
hx-vals, hx-includeДобавить к запросу значения или поля других элементов
hx-boostПревратить обычные ссылки и формы внутри элемента в AJAX-переходы
hx-swap-oobОбновить элемент вне hx-target тем же ответом

Форма с hx-post отправляет все свои поля, как обычная. Кнопка или ссылка с hx-post вне формы отправляет только значения из hx-vals, hx-include и атрибута name самого элемента.

hx-target: куда вставить ответ

По умолчанию ответ вставляется в сам элемент, который отправил запрос. hx-target принимает CSS-селектор (#results, .list) или относительный селектор:

  • this — сам элемент;
  • closest tr — ближайший родитель-строка таблицы;
  • find .errors — первый потомок с классом errors;
  • next div, previous div — соседний элемент.

Относительные селекторы избавляют от уникальных id в повторяющихся блоках: одна и та же строка таблицы работает для любой записи.

hx-swap: как вставить ответ

ЗначениеРезультатПример применения
innerHTML (по умолчанию)Заменяет содержимое целиОбновить блок результатов поиска
outerHTMLЗаменяет цель целикомСтрока таблицы ↔ форма редактирования
beforeend, afterbeginДобавляет в конец или начало целиНовый комментарий в ленте
beforebegin, afterendВставляет перед целью или после неёСледующая порция строк
deleteУдаляет цель независимо от ответаУдаление записи
noneНичего не вставляет, обрабатывает только заголовки ответаДействие, которое только шлёт событие

hx-trigger: когда отправить запрос

Без hx-trigger поля input, textarea и select отправляют запрос по событию change, форма — по submit, остальные элементы — по click. Свой триггер задаётся событием и модификаторами:

<input name="q" hx-get="{% url 'book_list' %}"
       hx-trigger="input changed delay:300ms" hx-target="#results">

<div hx-get="{% url 'stats' %}" hx-trigger="load, every 30s"></div>

<tr hx-get="?page=2" hx-trigger="revealed" hx-swap="outerHTML"></tr>
  • changed — запрос уходит, только если значение поля изменилось;
  • delay:300ms — ждёт паузу во вводе (debounce), throttle:1s — не чаще раза в секунду;
  • once — сработать один раз;
  • load — при появлении элемента в DOM, every 30s — опрос по таймеру;
  • revealed — элемент прокрутили в видимую область окна; внутри блока с собственной прокруткой используйте intersect once.

CSRF-защита в запросах HTMX

Django проверяет CSRF-токен у всех POST, PUT, PATCH и DELETE. Токен принимается из поля формы csrfmiddlewaretoken или из заголовка X-CSRFToken. Самый надёжный вариант — один раз передать заголовок на body:

<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>

В htmx 2 атрибуты наследуются, поэтому заголовок получат все запросы на странице — и формы, и кнопки с hx-delete вне форм. Обратите внимание: здесь нужна переменная {{ csrf_token }}. Тег {% csrf_token %} выводит скрытое поле input и в атрибут не подходит. Если вы меняли настройку CSRF_HEADER_NAME, используйте её имя заголовка.

Форма с hx-post и тегом {% csrf_token %} внутри тоже пройдёт проверку: HTMX отправит скрытое поле вместе с остальными. Тег стоит оставлять в формах в любом случае — без него не сработает обычная отправка, если JavaScript не загрузился.

Важно: в htmx 4 атрибуты больше не наследуются по умолчанию. На body нужно писать hx-headers:inherited='{"X-CSRFToken": "{{ csrf_token }}"}', иначе все POST- и DELETE-запросы получат 403.

Без токена Django отвечает 403 Forbidden, а htmx 2 не вставляет ответы с кодами 4xx — кнопка просто «ничего не делает». Ошибку видно только во вкладке Network инструментов разработчика. Ещё один случай: после входа через HTMX-запрос Django меняет CSRF-токен, а на странице остаётся старый. Поэтому после входа и выхода перезагружайте страницу — ответом HttpResponseClientRedirect или HttpResponseClientRefresh (о них — ниже).

Пакет django-htmx: request.htmx и ответы

HTMX можно использовать и без пакета: всё, что нужно серверу, приходит в заголовках, например request.headers.get("HX-Request") == "true". django-htmx делает эту работу удобнее.

Объект request.htmx

HtmxMiddleware добавляет к запросу атрибут request.htmx. В условии он истинен, только если запрос отправил HTMX:

def book_list(request):
    if request.htmx:
        ...  # вернуть фрагмент
    ...  # вернуть страницу целиком
АтрибутЗаголовокЧто содержит
bool(request.htmx)HX-RequestЗапрос отправил HTMX
request.htmx.boostedHX-BoostedПереход по ссылке или форме с hx-boost
request.htmx.targetHX-Targetid целевого элемента
request.htmx.trigger, trigger_nameHX-Trigger, HX-Trigger-Nameid и name элемента-источника (только htmx 2)
request.htmx.current_urlHX-Current-URLАдрес страницы в браузере
request.htmx.history_restore_requestHX-History-Restore-RequestВосстановление истории после промаха кеша
request.htmx.promptHX-PromptОтвет пользователя на hx-prompt
request.htmx.source, request_typeHX-Source, HX-Request-TypeИсточник запроса и тип full/partial (только htmx 4)

HttpResponseClientRedirect и другие ответы

Обычный redirect() в ответ на HTMX-запрос не сработает как ожидается: браузер молча выполнит редирект внутри AJAX-запроса, и HTMX вставит новую страницу целиком в маленький блок. Чтобы браузер перешёл по адресу полностью, верните HttpResponseClientRedirect — он отвечает кодом 200 с заголовком HX-Redirect:

from django.urls import reverse
from django_htmx.http import HttpResponseClientRedirect

return HttpResponseClientRedirect(reverse("book_list"))
Класс или функцияЗаголовок или кодДействие в браузере
HttpResponseClientRedirect(url)HX-RedirectПолный переход на url
HttpResponseClientRefresh()HX-Refresh: trueПерезагрузка текущей страницы
HttpResponseLocation(url)HX-LocationAJAX-переход без перезагрузки, как с hx-boost
HttpResponseStopPolling()код 286Остановить опрос every Ns
trigger_client_event(response, name)HX-TriggerВызвать событие в браузере, на которое подписаны другие блоки
push_url(response, url)HX-Push-UrlЗаписать адрес в историю браузера
retarget(response, selector), reswap(response, method)HX-Retarget, HX-ReswapИзменить цель и способ вставки из view

trigger_client_event связывает независимые блоки страницы: view добавления книги отправляет событие booksChanged, а счётчик с hx-trigger="booksChanged from:body" сам запрашивает новое значение.

Частичные шаблоны: страница и фрагмент из одного view

Главный приём HTMX в Django — один view, который отдаёт полную страницу при обычном переходе и только фрагмент при HTMX-запросе. Так страница работает по прямой ссылке, после перезагрузки и у поисковых роботов.

Отдельные шаблоны через include

Фрагмент выносят в отдельный файл и подключают через {% include %} в полный шаблон. View выбирает шаблон по request.htmx:

template = "books/_rows.html" if request.htmx else "books/book_list.html"
return render(request, template, context)

Способ работает в любой версии Django, но фрагменты разбегаются по десяткам мелких файлов.

Партиалы Django 6.0: partialdef

В Django 6.0 в язык шаблонов встроены партиалы — именованные фрагменты внутри одного шаблона. Фрагмент объявляют тегом {% partialdef имя %}, выводят тегом {% partial имя %}, а из view обращаются к нему по имени "шаблон.html#имя". Подробнее о синтаксисе — в статье про шаблоны Django.

{% partialdef book-form inline %}
<form>...</form>
{% endpartialdef %}
template = "books/book_form.html"
if request.htmx:
    template += "#book-form"  # только форма, без base.html

Аргумент inline выводит партиал на месте объявления при рендере полной страницы. Без него партиал только объявляется, а выводится тегом {% partial %} — так его можно вставить в цикл. На Django 5.2 LTS те же теги даёт пакет django-template-partials: установите его, добавьте "template_partials" в INSTALLED_APPS и {% load partials %} в шаблон.

Заголовок Vary

Один URL теперь отдаёт два разных ответа в зависимости от заголовка HX-Request. Об этом надо сообщить браузеру и кешам заголовком Vary, иначе возможна неприятная ошибка: пользователь уходит со страницы, нажимает «Назад» — и браузер показывает из кеша голый фрагмент без стилей и разметки. Помогает декоратор vary_on_headers:

from django.views.decorators.vary import vary_on_headers

@vary_on_headers("HX-Request")
def book_list(request):
    ...

Если на сайте есть hx-boost, проверяйте if request.htmx and not request.htmx.boosted: boosted-переход тоже отправляет HX-Request: true, но ждёт страницу целиком.

Рабочий пример: каталог книг

Соберём страницу каталога: поиск по мере ввода, подгрузка при прокрутке, редактирование строки на месте, удаление и форма добавления с серверной валидацией. Нужны проект с приложением books (как его создать, — в статье про startapp), установленный django-htmx и базовый шаблон base.html из раздела о подключении.

Модель и форма:

# books/models.py
from django.db import models


class Book(models.Model):
    title = models.CharField("Название", max_length=200)
    author = models.CharField("Автор", max_length=100)
    year = models.PositiveIntegerField("Год издания")

    class Meta:
        ordering = ["title"]

    def __str__(self):
        return self.title
# books/forms.py
from datetime import date

from django import forms

from .models import Book


class BookForm(forms.ModelForm):
    class Meta:
        model = Book
        fields = ["title", "author", "year"]

    def clean_year(self):
        year = self.cleaned_data["year"]
        if year > date.today().year:
            raise forms.ValidationError("Год издания не может быть в будущем.")
        return year

Маршруты:

# config/urls.py
from django.urls import path

from books import views

urlpatterns = [
    path("books/", views.book_list, name="book_list"),
    path("books/new/", views.book_create, name="book_create"),
    path("books/<int:pk>/", views.book_row, name="book_row"),
    path("books/<int:pk>/edit/", views.book_edit, name="book_edit"),
    path("books/<int:pk>/delete/", views.book_delete, name="book_delete"),
]

Живой поиск

Список, поиск и подгрузка страниц обслуживает один view. При HTMX-запросе он рендерит только партиал rows — строки таблицы:

# books/views.py
from django.core.paginator import Paginator
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.decorators.http import require_http_methods
from django.views.decorators.vary import vary_on_headers
from django_htmx.http import HttpResponseClientRedirect

from .forms import BookForm
from .models import Book


@vary_on_headers("HX-Request")
def book_list(request):
    q = request.GET.get("q", "").strip()
    books = Book.objects.all()
    if q:
        books = books.filter(title__icontains=q)
    page = Paginator(books, 20).get_page(request.GET.get("page"))

    template = "books/book_list.html"
    if request.htmx:
        template += "#rows"  # только строки таблицы
    return render(request, template, {"page": page, "q": q})

Шаблон templates/books/book_list.html объявляет два партиала: row — одна строка, rows — все строки страницы плюс строка-«датчик» для подгрузки следующей порции:

{% extends "base.html" %}

{% partialdef row %}
<tr id="book-{{ book.pk }}">
  <td>{{ book.title }}</td>
  <td>{{ book.author }}</td>
  <td>{{ book.year }}</td>
  <td>
    <button hx-get="{% url 'book_edit' book.pk %}"
            hx-target="closest tr" hx-swap="outerHTML">Изменить</button>
    <button hx-delete="{% url 'book_delete' book.pk %}"
            hx-confirm="Удалить книгу?"
            hx-target="closest tr" hx-swap="outerHTML">Удалить</button>
  </td>
</tr>
{% endpartialdef %}

{% partialdef rows %}
{% for book in page %}
  {% partial row %}
{% empty %}
  <tr><td colspan="4">Ничего не найдено</td></tr>
{% endfor %}
{% if page.has_next %}
<tr hx-get="{% url 'book_list' %}{% querystring page=page.next_page_number %}"
    hx-trigger="revealed" hx-swap="outerHTML">
  <td colspan="4">Загружаем ещё…</td>
</tr>
{% endif %}
{% endpartialdef %}

{% block content %}
<input type="search" name="q" value="{{ q }}" placeholder="Поиск по названию"
       hx-get="{% url 'book_list' %}"
       hx-trigger="input changed delay:300ms, search"
       hx-target="#book-rows"
       hx-sync="this:replace"
       hx-push-url="true">

<table>
  <tbody id="book-rows">
    {% partial rows %}
  </tbody>
</table>
{% endblock %}

Что делает поле поиска:

  • input changed delay:300ms — запрос уходит через 300 мс после последнего нажатия и только если текст изменился;
  • search — дополнительный триггер на очистку поля крестиком (событие поддерживают Chrome и Safari);
  • hx-sync="this:replace" — новый запрос отменяет незавершённый, и медленный ответ на «dj» не перезапишет результаты для «django»;
  • hx-push-url="true" — адрес /books/?q=django попадает в адресную строку: результатом можно поделиться, а после перезагрузки view отдаст полную страницу с тем же поиском.

Совет: в SQLite icontains не различает регистр только для латиницы. Запрос «мастерства» найдёт «Python. К вершинам мастерства», а «Мастерства» — нет. В PostgreSQL поиск без учёта регистра работает и для кириллицы.

Бесконечная прокрутка

Последняя строка каждой порции — «датчик» с hx-trigger="revealed". Когда пользователь докручивает до неё, HTMX запрашивает следующую страницу, а hx-swap="outerHTML" заменяет датчик новыми строками и новым датчиком. На последней странице page.has_next ложно, датчик не выводится, и подгрузка прекращается.

Тег {% querystring %} (Django 5.1+) берёт текущие GET-параметры и меняет только page. Без него вторая порция потеряет фильтр поиска и начнёт подгружать все книги подряд. Если строки таблицы лежат в блоке с собственной прокруткой, замените revealed на intersect once.

На больших таблицах Paginator с глубокими страницами замедляется из-за OFFSET. Тогда передавайте в датчик id последней записи и выбирайте следующую порцию через filter(pk__gt=last_id).

Inline-редактирование и удаление

Кнопка «Изменить» запрашивает форму и заменяет ею строку (hx-target="closest tr", hx-swap="outerHTML"). После сохранения view возвращает партиал row — строка снова становится обычной:

def book_row(request, pk):
    book = get_object_or_404(Book, pk=pk)
    return render(request, "books/book_list.html#row", {"book": book})


@require_http_methods(["GET", "POST"])
def book_edit(request, pk):
    book = get_object_or_404(Book, pk=pk)
    form = BookForm(request.POST or None, instance=book)
    if request.method == "POST" and form.is_valid():
        form.save()
        return render(request, "books/book_list.html#row", {"book": book})
    return render(request, "books/book_edit_row.html", {"book": book, "form": form})


@require_http_methods(["DELETE"])
def book_delete(request, pk):
    get_object_or_404(Book, pk=pk).delete()
    return HttpResponse("")  # пустой ответ: строка исчезнет из таблицы

Шаблон формы в строке templates/books/book_edit_row.html:

<tr id="book-{{ book.pk }}">
  <td colspan="4">
    <form hx-post="{% url 'book_edit' book.pk %}"
          hx-target="closest tr" hx-swap="outerHTML">
      {{ form.as_div }}
      <button type="submit">Сохранить</button>
      <button type="button" hx-get="{% url 'book_row' book.pk %}"
              hx-target="closest tr" hx-swap="outerHTML">Отмена</button>
    </form>
  </td>
</tr>

Если данные неверны, view вернёт ту же форму с ошибками, и она останется на месте. «Отмена» запрашивает исходную строку из book_row. Удаление работает через hx-delete: hx-confirm показывает диалог подтверждения браузера, пустой ответ с кодом 200 заменяет строку «ничем».

Важно: фрагменты — такие же URL, как страницы, и их может вызвать кто угодно. Проверяйте права в каждом view фрагмента: login_required, проверку владельца в get_object_or_404(Book, pk=pk, owner=request.user). Изменяющие действия делайте только через hx-post, hx-patch или hx-delete — на GET-запросы CSRF-защита не распространяется.

Отправка формы с валидацией

Форма добавления книги работает и без JavaScript (атрибуты method и action), и через HTMX. При ошибке view возвращает только партиал формы с сообщениями, при успехе — HttpResponseClientRedirect на список:

@require_http_methods(["GET", "POST"])
def book_create(request):
    form = BookForm(request.POST or None)
    if request.method == "POST" and form.is_valid():
        form.save()
        if request.htmx:
            return HttpResponseClientRedirect(reverse("book_list"))
        return redirect("book_list")

    template = "books/book_form.html"
    if request.htmx:
        template += "#book-form"
    return render(request, template, {"form": form})
{% extends "base.html" %}

{% block content %}
<h1>Новая книга</h1>
{% partialdef book-form inline %}
<form method="post" action="{% url 'book_create' %}"
      hx-post="{% url 'book_create' %}"
      hx-target="this" hx-swap="outerHTML"
      hx-disabled-elt="find button">
  {% csrf_token %}
  {{ form.as_div }}
  <button type="submit">Сохранить</button>
</form>
{% endpartialdef %}
{% endblock %}

hx-target="this" и hx-swap="outerHTML" заменяют форму её же новой версией с ошибками. hx-disabled-elt="find button" блокирует кнопку до ответа сервера, чтобы двойной клик на медленном соединении не создал две записи. Валидация описана один раз — в BookForm, как в обычной форме Django, и дублировать её на JavaScript не нужно.

Если отправить пустое название и 2999 год, HTMX заменит форму версией с сообщениями «Обязательное поле.» и «Год издания не может быть в будущем.» — при LANGUAGE_CODE = "ru" стандартные ошибки Django выводятся на русском.

Важно: невалидную форму возвращайте с кодом 200. htmx 2 не вставляет ответы 4xx и 5xx, и форма с кодом 422 просто не появится. В htmx 4 ответы с ошибками вставляются, а поведение для отдельных кодов настраивается атрибутом hx-status.

Проверка через тестовый клиент Django

Логика HTMX-приложения живёт в view, поэтому её проверяют обычными тестами. Тестовый клиент Django принимает аргумент headers, и HTMX-запрос имитируется одним заголовком:

# books/tests.py
from django.test import Client, TestCase

from .models import Book

HX = {"HX-Request": "true"}


class BookHtmxTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        Book.objects.bulk_create(
            Book(title=f"Django {i:02}", author="Автор", year=2020)
            for i in range(1, 46)
        )

    def test_full_page_and_fragment(self):
        full = self.client.get("/books/")
        part = self.client.get("/books/", headers=HX)
        self.assertContains(full, "<html")
        self.assertNotContains(part, "<html")
        self.assertEqual(part.content.decode().count("<tr id="), 20)
        self.assertIn("HX-Request", full["Vary"])

    def test_infinite_scroll_keeps_query(self):
        r = self.client.get("/books/", {"q": "django", "page": 2}, headers=HX)
        self.assertContains(r, 'hx-get="/books/?q=django&amp;page=3"')

    def test_invalid_form_returns_200(self):
        data = {"title": "", "author": "X", "year": 2999}
        r = self.client.post("/books/new/", data, headers=HX)
        self.assertEqual(r.status_code, 200)
        self.assertContains(r, "Год издания не может быть в будущем.")

    def test_success_uses_client_redirect(self):
        data = {"title": "Новая", "author": "X", "year": 2020}
        r = self.client.post("/books/new/", data, headers=HX)
        self.assertEqual(r["HX-Redirect"], "/books/")

    def test_csrf_header(self):
        client = Client(enforce_csrf_checks=True)
        token = str(client.get("/books/").context["csrf_token"])
        url = f"/books/{Book.objects.first().pk}/delete/"
        self.assertEqual(client.delete(url, headers=HX).status_code, 403)
        headers = {**HX, "X-CSRFToken": token}
        self.assertEqual(client.delete(url, headers=headers).status_code, 200)
python manage.py test books
Found 5 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 0.093s

OK
Destroying test database for alias 'default'...

Тесты подтверждают, что view отдаёт полную страницу и фрагмент из 20 строк, датчик прокрутки сохраняет поиск, ошибки формы приходят с кодом 200, а запрос без CSRF-заголовка получает 403. Опечатку в hx-target такие тесты не поймают — для ключевых сценариев добавьте пару браузерных тестов на Playwright или Selenium. Как организовать тесты удобнее, — в статье про pytest-django.

Частые ошибки

СимптомПричинаРешение
Кнопка ничего не делает, в Network ответ 403Нет CSRF-токенаhx-headers с X-CSRFToken на body (в htmx 4 — с :inherited)
Ошибки формы не появляютсяView вернул 400 или 422Возвращайте невалидную форму с кодом 200
Внутри блока появилась вся страница с шапкойView отдал полный шаблон на HTMX-запросВетка if request.htmx с партиалом
Внутри блока появилась страница входаСессия истекла, login_required сделал редиректMiddleware, превращающий такой редирект в HX-Redirect (ниже)
«Назад» показывает фрагмент без стилейНет заголовка Vary: HX-RequestДекоратор vary_on_headers("HX-Request")
В новом фрагменте не работают JS-виджетыСкрипт инициализировал их только при загрузке страницыИнициализация в htmx.onLoad(callback)
Результаты поиска «прыгают»Ответы на старые запросы приходят позже новыхhx-sync="this:replace"

Проблема с истекшей сессией проявляется только в продакшене, когда вкладка открыта всю ночь. Небольшой middleware переводит редирект на страницу входа в полный переход браузера:

# books/middleware.py
from django.conf import settings
from django.shortcuts import resolve_url
from django_htmx.http import HttpResponseClientRedirect


class HtmxLoginRedirectMiddleware:
    """Редирект на страницу входа для HTMX-запросов делаем полным переходом."""

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        login_url = resolve_url(settings.LOGIN_URL)
        if (
            request.htmx
            and response.status_code == 302
            and response.url.startswith(login_url)
        ):
            return HttpResponseClientRedirect(response.url)
        return response

Подключите его в MIDDLEWARE после django_htmx.middleware.HtmxMiddleware. HTMX-запрос к закрытому view получит код 200 и заголовок HX-Redirect: /accounts/login/?next=/secret/, обычный запрос — прежний редирект 302.

Когда HTMX не подходит

HTMX закрывает большинство задач сайтов с серверной отрисовкой: каталоги, личные кабинеты, внутренние панели, CRUD-интерфейсы, формы с проверкой на сервере. Ограничение одно и принципиальное: каждое действие — это запрос к серверу.

КритерийHTMXAlpine.jsSPA (React, Vue) + API
Где рендерится HTMLНа сервере, в шаблонах DjangoВ браузере, поверх серверного HTMLВ браузере
Что отдаёт DjangoHTML-фрагментыСтраницы, иногда JSONJSON, обычно через DRF
Реакция на действиеПосле ответа сервераМгновенная, без запросаМгновенная, данные подгружаются отдельно
Работа без сетиНетТолько локальный UIВозможна (PWA, offline-first)
Сборка фронтендаНе нужнаНе нужнаНужна
Типичные задачиПоиск, фильтры, формы, подгрузкаМеню, вкладки, модальные окнаРедакторы, совместная работа, сложные дашборды

Выбирайте SPA, если интерфейс сложнее набора страниц: графический или текстовый редактор, совместное редактирование в реальном времени, офлайн-режим, много состояния в браузере. Отдельный API на Django REST Framework понадобится и тогда, когда тот же бэкенд обслуживает мобильное приложение.

Alpine.js хорошо сочетается с HTMX: HTMX отвечает за общение с сервером, Alpine.js — за чисто клиентские мелочи вроде раскрывающегося меню, которым не нужен запрос. Для обновлений в реальном времени у HTMX есть расширения для Server-Sent Events и WebSocket, а на стороне Django — Django Channels.

Частые вопросы

Обязательно ли устанавливать django-htmx?

Нет. HTMX работает с любым бэкендом: достаточно подключить скрипт и проверять заголовок request.headers.get("HX-Request"). Пакет экономит код: даёт request.htmx с разобранными заголовками, готовые классы ответов вроде HttpResponseClientRedirect, тег htmx_script со встроенной htmx и отладочный скрипт для режима DEBUG.

Как показать индикатор загрузки?

Добавьте элемент с классом htmx-indicator и укажите его в hx-indicator. На время запроса HTMX делает индикатор видимым, после ответа снова скрывает:

<input name="q" hx-get="{% url 'book_list' %}" hx-target="#book-rows"
       hx-trigger="input changed delay:300ms" hx-indicator="#spinner">
<span id="spinner" class="htmx-indicator">Ищем…</span>

Как обновить несколько частей страницы одним ответом?

Добавьте в ответ элементы с атрибутом hx-swap-oob="true" и тем же id, что на странице. HTMX вставит основной фрагмент в hx-target, а такие элементы — на их места. Например, вместе с новой строкой таблицы view может вернуть <span id="book-count" hx-swap-oob="true">47</span>, и счётчик в шапке обновится. Если зависимых блоков больше двух-трёх, удобнее отправить событие через trigger_client_event и дать каждому блоку обновиться самостоятельно.

Как передать в запрос дополнительные данные?

hx-vals добавляет значения в формате JSON, hx-include — поля других элементов по CSS-селектору:

<button hx-post="{% url 'book_publish' book.pk %}"
        hx-vals='{"notify": "1"}'
        hx-include="[name='comment']">Опубликовать</button>

Во view значения придут в request.POST (для hx-get — в request.GET), как поля обычной формы.

Как перейти на htmx 4?

Сначала прогоните проверку шаблонов: npx htmx.org@4.0.0 upgrade-check -- ./templates. Утилита найдёт атрибуты, которым нужен суффикс :inherited (в первую очередь hx-headers с CSRF-токеном), переименованные атрибуты и старые имена событий. Затем в django-htmx замените {% htmx_script %} на {% htmx_script version=4 %}. На время миграции можно подключить расширение htmx-2-compat: {% htmx_script version=4 extensions="htmx-2-compat" %}. Учтите, что в django-htmx 1.29 встроена бета-версия htmx 4 (4.0.0-beta6), а для финальной 4.0.0 нужен CDN или собственный файл в статике.

Итог

  • HTMX добавляет в Django интерактивность атрибутами: hx-get и hx-post отправляют запрос, hx-target и hx-swap определяют, куда и как вставить ответ, hx-trigger — когда отправлять.
  • Подключайте конкретную версию: с CDN, из статики или тегом htmx_script из django-htmx. Стабильная линия — htmx 2.0.11, htmx 4.0 вышел и помечен как next.
  • Для POST и DELETE передавайте CSRF-токен через hx-headers на body; в htmx 4 — с суффиксом :inherited.
  • Один view отдаёт полную страницу или фрагмент по request.htmx. Фрагменты удобно объявлять партиалами Django 6.0 и рендерить по имени "шаблон.html#партиал"; добавляйте Vary: HX-Request.
  • Невалидную форму возвращайте с кодом 200, для перехода после успешного действия используйте HttpResponseClientRedirect.
  • HTMX подходит для сайтов и панелей с серверной логикой. Для офлайн-режима, редакторов и сложного состояния в браузере нужен SPA с API.

Если вы только знакомитесь с фреймворком, начните с обзора Django для начинающих, а затем добавляйте HTMX к готовым страницам.

  • #Django
  • #HTMX
  • #django-htmx
  • #Шаблоны
  • #Frontend
↑ ↓ выборEnter открытьEsc закрыть