В былые времена создание веб-сайтов было несложным процессом. Достаточно было HTML и CSS, чтобы всё работало просто. Сегодня же фреймворки JavaScript повсюду. Постоянные обновления, растущая сложность. Это явление получило название "усталость от JavaScript", когда разработчики устают от погони за новыми фреймворками, инструментами сборки, библиотеками и пытаются не отставать. Благодаря HTMX разработчики могут создавать увлекательные веб-приложения проще, с меньшим выгоранием и без лишних хлопот с JavaScript.
Увлекательное веб-приложение вроде ChatGPT можно реализовать менее чем в 200 строках кода на чистом Python и HTML. Вот пример такого:

Краткий обзор принципов работы веба
Когда Тим Бернерс-Ли в 1990 году разработал первую веб-страницу, его система в основном представляла собой "режим только для чтения", где страницы связывались гиперссылками, известными как теги якоря в HTML. Таким образом, HTML 1.0 опирался на единственный тег и обеспечивал базовую навигацию между страницами.
<!-- Исходный веб: простая гипермедиа -->
<a href="/about">О нас</a>Тег якоря служит элементом управления гипермедиа, выполняющим следующий процесс:
- показывает пользователю, что это ссылка (на которую можно кликнуть);
- отправляет GET-запрос по URL гиперссылки.
Когда сервер возвращает новую страницу, браузер заменяет текущую страницу на новую (навигация).
Затем появился Web 2.0, который ввёл новый тег — тег формы. Этот тег позволял обновлять ресурсы в дополнение к их чтению через тег <code><a></code>. Возможность обновления ресурсов открыла путь к настоящим веб-приложениям. Всё это достигалось с помощью всего двух элементов управления: <code><form></code> и <code><a></code>.
<!-- Web 2.0: теперь можно обновлять данные -->
<form method="POST" action="/login">
<input type="email" name="email" required>
<input type="password" name="password" required>
<button type="submit">Войти</button>
</form>Процесс отправки формы похож на работу тега якоря, но с возможностью:
- выбрать тип запроса (GET или POST);
- прикрепить информацию пользователя, такую как email, пароль и т.д., для передачи с запросом.
Эти два тега — единственные элементы в чистом HTML, способные взаимодействовать с сервером.
Затем появился JavaScript
JavaScript изначально создавался для добавления простых взаимодействий на веб-страницы: валидация форм, загрузка данных и базовые анимации. Но с введением XMLHttpRequest (позже известного как AJAX) JavaScript превратился в нечто гораздо более мощное и сложное.
С JavaScript разработчики получили возможность запускать HTTP-запросы без использования двух указанных тегов, применяя AJAX. AJAX позволяет загружать данные с сервера, и хотя XHR может получать любые типы данных, включая фрагменты сырого HTML, текст или XML, JSON стал фактическим стандартом обмена данными.
Это подразумевает дополнительный этап, где JSON преобразуется в HTML с помощью функции, генерирующей HTML из JSON. Как показано в примере ниже, процесс включает:
- загрузку JSON-данных с конечной точки <code>/api/users</code> (часть <code>response => response.json()</code>);
- вставку этих данных в HTML-шаблоны (часть <code>const html</code>);
- добавление результата в DOM (часть <code>document.getElementById()</code>).
// Подход с JavaScript: преобразование JSON в HTML
fetch('/api/users')
.then(response => response.json())
.then(users => {
const html = users.map(user => `<div>${user.name}</div>`)
.join('');
document.getElementById('users').innerHTML = html;
});Такой рендеринг создаёт тесную связь между форматом JSON-данных и самой функцией: если формат JSON изменится, это может сломать генерацию HTML. Здесь уже видна одна потенциальная проблема, которая часто вызывает трения между фронтенд- и бэкенд-разработчиками: фронтендер строит интерфейс на основе ожидаемого формата JSON, бэкенд-разработчик меняет формат, фронтендер обновляет интерфейс, бэкенд меняет снова и так далее.
По какой-то причине веб-разработчики начали использовать JSON повсеместно и управлять всем через JavaScript. Это привело к появлению одностраничных приложений (SPA): в отличие от традиционного HTML 2.0, больше нет навигации между страницами. Всё содержимое остаётся на одной странице, а обновления происходят через JavaScript и рендеринг интерфейса. Именно так работают фреймворки вроде React, Angular, Vue.js.
"Появляющаяся норма веб-разработки — создание одностраничного приложения на React с серверным рендерингом. Два ключевых элемента этой архитектуры примерно такие:
— Основной интерфейс строится и обновляется на JavaScript с помощью React или аналогичного.
— Бэкенд — это API, к которому приложение отправляет запросы.
Эта идея захватила интернет. Она началась с нескольких крупных популярных сайтов и проникла в углы вроде маркетинговых сайтов и блогов."(Tom MacWright, https://macwright.com/2020/05/10/spa-fatigue)
Большинство современных архитектур SPA — это "толстые клиентские" приложения, где основная работа выполняется на стороне клиента, а бэкенд лишь возвращает JSON через API. Такая настройка известна своей отзывчивостью и плавностью пользовательского опыта, но действительно ли нужна такая сложность всегда?
"(…) также есть множество проблем, для которых я не вижу конкретной пользы в использовании React. Это вещи вроде блогов, сайтов с корзиной покупок, в основном CRUD- и форм-ориентированных сайтов."
(Tom MacWright, https://macwright.com/2020/05/10/spa-fatigue)
Усталость от JavaScript — реальная проблема
"Усталость от JavaScript" становится всё громче. Она отражает основные недостатки разработки SPA:
- Растущая сложность: Библиотеки и фреймворки становятся всё тяжелее и сложнее, требуя больших команд для управления. Некоторые навязчивые фреймворки заставляют разработчиков JavaScript специализироваться на одной технологии. Ни один разработчик Python не называет себя "разработчиком Python на TensorFlow". Они просто разработчики Python, и переход от TF к PyTorch всё равно позволяет читать и использовать оба.
- Тесная связь: Связь между API данных и интерфейсом создаёт трения в командах. Разрывы изменений происходят ежедневно, и нет способа решить это, пока команды используют JSON как интерфейс обмена.
- Распространение фреймворков: Количество фреймворков продолжает расти, вызывая настоящее чувство "усталости" у разработчиков JavaScript.
- Переусложнение: Тяжёлые фреймворки на JavaScript не нужны в 90% случаев. А в некоторых (приложения с большим объёмом контента) это даже плохая идея.
Кроме высоко интерактивных/коллаборативных интерфейсов, простого HTML с многстраничными приложениями часто достаточно.
Что такое HTMX?
HTMX — это очень лёгкая библиотека JavaScript (14 КБ), предлагающая HTML-ориентированный подход к созданию динамических веб-приложений. Она расширяет HTML, позволяя любому элементу выполнять AJAX-запросы и обновлять любую часть DOM. В отличие от фреймворков JavaScript, где весь рендеринг происходит на клиенте, основная работа выполняется сервером, возвращающим фрагменты HTML для вставки в DOM. Это также означает, что если вы уже знакомы с шаблонизаторами и HTML, кривая обучения будет гораздо проще, чем освоение React или Angular.
Вместо отказа от гипермедиа в пользу JSON-API, HTMX делает HTML более мощным с помощью:
- Любой элемент может выполнять HTTP-запросы (не только <code><a></code> и <code><form></code>);
- Любой HTTP-метод (GET, POST, PUT, DELETE, PATCH);
- Любой элемент может быть целью обновления;
- Любое событие может запускать запросы (клик, отправка, загрузка и т.д.).
Фактически, вы можете создать свой собственный интерфейс вроде GPT с HTMX и всего несколькими строками Python!
Реальная демонстрация: приложение ChatGPT на HTMX и FastAPI
В этой статье мы создадим простой чат менее чем в 100 строках Python и HTML. Начнём с базовых примеров, чтобы показать, как работает HTMX, затем добавим интерфейс чата и поддержку потоковой передачи. Чтобы сделать это ещё интереснее, используем Google Agent Development Toolkit, чтобы задействовать агенты в чате!
Простые демонстрации HTMX
Предположим, у нас есть API, возвращающее список пользователей. Мы хотим кликнуть кнопку, чтобы загрузить данные и отобразить список.

Традиционный подход с JavaScript:
<!-- Традиционный подход с JavaScript -->
<!DOCTYPE html>
<html>
<head>
<title>Демо</title>
</head>
<body>
<h1>Пользователи</h1>
<button>Показать</button>
<div>
<ul id="usersList"></ul>
</div>
<script>
function getUsers() {
fetch('https://dummyjson.com/users')
.then(res => res.json())
.then(data => {
const usersList = document.getElementById('usersList');
if (usersList) {
data.users.forEach(user => {
const listItem = document.createElement('li');
listItem.textContent = `${user.firstName} ${user.lastName}`;
usersList.appendChild(listItem);
});
}
})
.catch(error => {
console.error('Error fetching users:', error);
});
}
</script>
</body>
</html>А вот как это сделать с HTMX.
Сначала создайте бэкенд:
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
import requests
app = FastAPI()
templates = Jinja2Templates(directory="templates")
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
return templates.TemplateResponse("demo.html", {"request": request})
@app.get("/users")
async def get_users():
r = requests.get("https://dummyjson.com/users")
data = r.json()
html = ""
for row in data['users']:
html += f"<li>{row['firstName']} {row['lastName']}</li>\n"
return HTMLResponse(html)Затем HTML:
<!-- Подход с HTMX -->
<!DOCTYPE html>
<html>
<head>
<title>Демо</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@1.9.10/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script>
</head>
<body>
<h1>Пользователи</h1>
<button hx-get="/users" hx-target="#usersList" hx-swap="innerHTML">Показать</button>
<div>
<ul id="usersList"></ul>
</div>
</body>
</html>Результат будет идентичным! Что произошло? Посмотрите на элемент <code><button></code>. Видны три атрибута, начинающиеся с <code>hx-</code>. Зачем они?
- <code>hx-get</code>: Клик по кнопке запустит GET-запрос к конечной точке <code>/users</code>;
- <code>hx-target</code>: Указывает браузеру заменить содержимое элемента с id <code>usersList</code> HTML-данными от сервера;
- <code>hx-swap</code>: Указывает браузеру вставить HTML внутрь целевого элемента.
Теперь вы знаете, как использовать HTMX. Преимущество такого подхода в том, что изменения HTML не сломают ничего на странице.
Конечно, у HTMX есть плюсы и минусы. Но как разработчику Python приятно работать с бэкендом на FastAPI, не беспокоясь о рендеринге HTML. Просто добавьте шаблоны Jinja, немного Tailwind CSS — и готово!
Первый чат на HTMX и FastAPI
Теперь перейдём к серьёзному. В первый шаг создадим простого бота, который принимает запрос пользователя и возвращает его задом наперёд. Для этого построим страницу с:
- списком сообщений;
- текстовым полем для ввода пользователя.
HTMX займётся отправкой и получением сообщений! Вот как это выглядит:

Обзор
Поток работает так:
- Пользователь вводит запрос в текстовое поле;
- Это поле обёрнуто в форму, которая отправит POST-запрос на сервер с параметром <code>query</code>.
- Бэкенд получает запрос, обрабатывает <code>query</code> (в реальности можно использовать LLM для ответа). В нашем случае для демонстрации просто перевернём запрос посимвольно.
- Бэкенд обернёт ответ в HTMLResponse (не JSON!).
- В форме HTMX укажет браузеру, куда вставить ответ (<code>hx-target</code>), и как заменить текущий DOM.
И это всё. Начнём!
Бэкенд
Определим маршрут <code>/send</code>, ожидающий строку <code>query</code> от фронтенда, перевернёт её и вернёт в теге <code><li></code>.
from fastapi import FastAPI, Request, Form
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
app = FastAPI()
templates = Jinja2Templates("templates")
@app.get("/")
async def root(request: Request):
return templates.TemplateResponse("simple_chat_sync.html", {"request": request})
@app.post("/send")
async def send_message(query: str = Form(...)):
message = "".join(list(query)[::-1])
html = f"<li class='mb-6 justify-end flex'><div class='max-w-[70%] bg-black text-white rounded-xl px-4 py-2'><div class='font-bold text-right'>AI</div><div>{message}</div></div></li>"
return HTMLResponse(html)Фронтенд
На стороне фронтенда определим простую HTML-страницу с Tailwind CSS и HTMX:
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@1.9.10/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@1.9.10" integrity="sha384-A986SAtodyH8eg8x8irJnYUk7i9inVQqYigD6qZ9evobksGNIXfeFvDwLSHcp31N" crossorigin="anonymous"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Merriweather:wght@400&display=swap" rel="stylesheet">
<style>
body { font-family: "Merriweather"; }
</style>
</head>
<body>
<main>
<header> ZeChat </header>
<div id="chat">
<ul></ul>
</div>
<footer>
<form hx-post="/send" hx-target="#chat" hx-swap="beforeend" hx-trigger="click from:#submitButton" hx-on::before-request=" htmx.find('#chat').innerHTML += `<li class='mb-6 justify-start flex'><div class='max-w-[70%] border border-black rounded-xl px-4 py-2'><div class='font-bold'>Me</div><div>${htmx.find('#query').value}</div></div></li>`; htmx.find('#query').value = ''; ">
<textarea id="query" name="query" placeholder="Write a message..." rows="4"></textarea>
<button id="submitButton" type="submit">Send</button>
</form>
</footer>
</main>
</body>
</html>Взглянем ближе на тег <code><form></code>. У него несколько атрибутов, разберём их:
- <code>hx-post="/send"</code>: Выполнит POST-запрос к конечной точке <code>/send</code>;
- <code>hx-trigger="click from:#submitButton"</code>: Запрос сработает при клике на <code>submitButton</code>;
- <code>hx-target="#chat"</code>: Указывает, куда поместить HTML-ответ. Здесь — добавить к списку;
- <code>hx-swap="beforeend"</code>: <code>hx-target</code> говорит куда, <code>hx-swap</code> — как. Здесь содержимое добавится перед концом (после последнего ребёнка).
<code>hx-on::before-request</code> сложнее, но просто: это происходит между кликом и отправкой запроса. Добавляет ввод пользователя в конец списка и очищает поле. Так достигается отзывчивый опыт!
Улучшенный чат (потоковая передача + LLM)
То, что мы создали, — простой, но рабочий чат. Однако для подключения LLM ответ сервера может занимать время. Текущий чат синхронный: ничего не происходит, пока LLM не закончит. Не лучший опыт для пользователя.
Теперь нужны потоковая передача и настоящий LLM для разговора. Об этом — во второй части.