← все статьи
8 мин

htmx и возврат к серверному рендерингу

Типичный сценарий: нужен внутренний дашборд для операционной команды. Таблица заказов, фильтры, пара форм. Разработчик открывает Create React App (или Vite с Vue), настраивает роутинг, придумывает API-контракт, пишет state management — и через два дня у него есть архитектура, но нет продукта. htmx предлагает другой путь: HTML, который умеет делать запросы.

Что такое htmx и почему это не «старый подход»

htmx — это библиотека (14 KB gzip), которая расширяет HTML атрибутами для AJAX-запросов, WebSocket, SSE и управления DOM без JavaScript на стороне разработчика. Ключевая идея — hypermedia as the engine of application state (HATEOAS): сервер возвращает HTML-фрагменты, браузер вставляет их в нужное место.

Версия 2.0 вышла в июне 2024 и принесла breaking changes: убрала устаревшие атрибуты hx-on в пользу явного синтаксиса, улучшила обработку событий и добавила hx-validate для нативной HTML5-валидации форм.

Это не возврат к PHP 2005 года. Разница принципиальная: вы по-прежнему пишете чистый Python/Go/Ruby на сервере, используете нормальные шаблоны, тестируете бизнес-логику без браузера. Просто отказываетесь от лишнего слоя сложности.

Ключевые атрибуты: минимальный словарь

Весь htmx умещается в несколько атрибутов. Базовый набор, который покрывает 90% задач:

<!-- Загрузить HTML-фрагмент GET-запросом и вставить вместо элемента -->
<div hx-get="/orders/list"
     hx-trigger="load"
     hx-target="#orders-container"
     hx-swap="innerHTML">
  Загрузка...
</div>

<!-- Форма: отправить POST, заменить форму ответом сервера -->
<form hx-post="/orders/create"
      hx-target="#order-form"
      hx-swap="outerHTML">
  <input name="title" required />
  <button type="submit">Создать</button>
</form>

<!-- Кнопка с подтверждением -->
<button hx-delete="/orders/42"
        hx-target="closest tr"
        hx-swap="outerHTML swap:300ms"
        hx-confirm="Удалить заказ?">
  Удалить
</button>

<!-- Поиск с debounce: запрос через 400 мс после последнего ввода -->
<input type="search"
       name="q"
       hx-get="/orders/search"
       hx-trigger="keyup changed delay:400ms"
       hx-target="#search-results"
       placeholder="Поиск по заказам..." />

hx-swap контролирует, куда вставляется ответ: innerHTML, outerHTML, beforebegin, afterend, delete. Модель простая и предсказуемая.

Реальный пример: дашборд на FastAPI + htmx + Jinja2

Возьмём конкретную задачу — дашборд заказов с фильтрацией по статусу и пагинацией. На React это минимум 150 строк JS с хуками. На htmx — несколько шаблонов и роутов.

# main.py
from fastapi import FastAPI, Request, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse

app = FastAPI()
templates = Jinja2Templates(directory="templates")

# Фейковая БД для примера
ORDERS = [
    {"id": i, "title": f"Заказ #{i}", "status": ["new", "processing", "done"][i % 3]}
    for i in range(1, 51)
]

@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
    return templates.TemplateResponse("index.html", {"request": request})

@app.get("/orders/list", response_class=HTMLResponse)
async def orders_list(
    request: Request,
    status: str = Query(default=""),
    page: int = Query(default=1),
):
    per_page = 10
    filtered = [o for o in ORDERS if not status or o["status"] == status]
    total = len(filtered)
    items = filtered[(page - 1) * per_page : page * per_page]

    # htmx ожидает HTML-фрагмент, не JSON
    return templates.TemplateResponse("partials/orders_list.html", {
        "request": request,
        "orders": items,
        "page": page,
        "total_pages": (total + per_page - 1) // per_page,
        "status": status,
    })

@app.post("/orders/{order_id}/status", response_class=HTMLResponse)
async def update_status(request: Request, order_id: int, new_status: str = Query(...)):
    order = next((o for o in ORDERS if o["id"] == order_id), None)
    if order:
        order["status"] = new_status
    return templates.TemplateResponse("partials/order_row.html", {
        "request": request,
        "order": order,
    })
<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="UTF-8">
  <title>Дашборд заказов</title>
  <script src="https://unpkg.com/htmx.org@2.0.3"></script>
</head>
<body>
  <h1>Заказы</h1>

  <!-- Фильтр по статусу -->
  <select hx-get="/orders/list"
          hx-target="#orders-container"
          hx-include="[name='status']"
          name="status">
    <option value="">Все</option>
    <option value="new">Новые</option>
    <option value="processing">В работе</option>
    <option value="done">Выполнены</option>
  </select>

  <!-- Контейнер: загружается сразу при открытии страницы -->
  <div id="orders-container"
       hx-get="/orders/list"
       hx-trigger="load">
  </div>
</body>
</html>

<!-- templates/partials/orders_list.html -->
<table>
  <thead>
    <tr><th>ID</th><th>Заказ</th><th>Статус</th></tr>
  </thead>
  <tbody>
    {% for order in orders %}
    {% include "partials/order_row.html" %}
    {% endfor %}
  </tbody>
</table>

<!-- Пагинация -->
{% if page > 1 %}
<button hx-get="/orders/list?page={{ page - 1 }}&status={{ status }}"
        hx-target="#orders-container">
  ← Назад
</button>
{% endif %}
{% if page < total_pages %}
<button hx-get="/orders/list?page={{ page + 1 }}&status={{ status }}"
        hx-target="#orders-container">
  Вперёд →
</button>
{% endif %}

Весь интерактив — 0 строк кастомного JavaScript. Сервер рендерит HTML-фрагменты, htmx вставляет их в DOM. Логика пагинации и фильтрации полностью на сервере, тестируется обычными unit-тестами.

htmx vs Turbo vs Livewire

Альтернативы существуют, и выбор зависит от стека:

Turbo (Hotwire) — часть Rails-экосистемы. Turbo Drive (перехватывает навигацию), Turbo Frames (частичные обновления), Turbo Streams (WebSocket/SSE). Мощнее из коробки, но заточен под Ruby on Rails — вне этого стека ощущается как чужеродное тело.

Livewire — для Laravel/PHP. Компонентная модель, двусторонняя привязка данных. По ощущениям ближе к Vue, но рендеринг на сервере. Хорош внутри PHP-стека, не применим за его пределами.

htmx — language-agnostic. Работает с любым сервером, который умеет отдавать HTML: Python, Go, Rust, Node, PHP. Минимальные соглашения, максимальная совместимость.

# Сравнение бандла (только клиентский JS):
# React + ReactDOM (production):   ~140 KB gzip
# Vue 3 (runtime):                 ~34 KB gzip
# htmx 2.0:                        ~14 KB gzip
# Alpine.js (для мелкой реактивности): ~15 KB gzip
#
# htmx + Alpine.js вместе: ~29 KB — полноценный интерактив
# без build step, без node_modules, без webpack

Boosted links и прогрессивное улучшение

Атрибут hx-boost="true" на родительском элементе превращает обычные ссылки и формы в AJAX-запросы автоматически. Страница ведёт себя как SPA — без перезагрузки, с обновлением только <body> — но деградирует до обычного HTML при отключённом JS:

<!-- Все ссылки внутри nav становятся AJAX без изменения href -->
<nav hx-boost="true">
  <a href="/dashboard">Дашборд</a>
  <a href="/orders">Заказы</a>
  <a href="/settings">Настройки</a>
</nav>

История браузера (pushState) обновляется автоматически. SEO-индексация работает — контент в HTML, не в JS.

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

Честный список ограничений важнее маркетинга:

  • Rich interactions: drag-and-drop, сложные canvas-интерфейсы, редакторы типа Figma или Google Docs — это React/Vue territory. htmx не заменяет JS для сложной клиентской логики.
  • Offline-first приложения: htmx требует сетевого подключения для каждого взаимодействия. PWA со service workers и локальным кэшем — не его сценарий.
  • Мобильные приложения: React Native, Flutter. htmx — только веб.
  • Публичные API: если сторонние клиенты потребляют ваш API, JSON контракт неизбежен. htmx не отменяет необходимость в API — он добавляет HTML-эндпоинты рядом с JSON.
  • Команда уже знает React: переучивание команды ради «меньше JS» — сомнительный ROI. htmx лучше всего работает там, где серверный шаблонизатор уже в стеке.

Хорошее правило: если UI-логика сложнее, чем «показать/скрыть, загрузить список, отправить форму» — добавьте Alpine.js для локального state. Если этого не хватает — рассмотрите частичное использование React для конкретных компонентов, оставив htmx для остального.

React решает проблемы, которые возникают после того, как ваш продукт вырос. htmx решает проблему прямо сейчас: меньше инструментов, меньше сложности, работающий продукт быстрее. Для большинства внутренних инструментов, дашбордов и CRUD-приложений архитектурный overhead SPA — это преждевременная оптимизация в обратную сторону.


← все статьи Следующая →CSS Container Queries: конец медиа-запросов?