was a time, long ago, when building websites was easy. HTML and CSS. It felt simple. Nowadays, Javascript frameworks are everywhere. Relentless change, increasing complexity. This phenomenon is called “Javascript Fatigue” and is all about developers exhausted by chasing the latest frameworks, build tools, libraries, and trying to keep the pace. With HTMX, developers now have a way to build engaging web applications with greater simplicity and less burnout — and without all the JS hassle.
An engaging web application like ChatGPT, in less than 200 lines of code, pure Python and HTML. Like this one:

A quick refresher on how the Web worked
When Tim Berners-Lee created the first web page in 1990, the system he designed was mostly a “read-only” system, that would lead to pages connected between themselves with hyperlinks, which we all know as anchor tags in HTML. HTML 1.0 was therefore relying on one single tag and offered simple navigation between pages.
About UsThe anchor tag is a hypermedia control that does the following process:
- show the user that this is a link (clickable)
- issue a GET request to the hyperlink URL
When the server responds with a new page, the browser will replace the current page with the new page (navigation)
Then came Web 2.0 which introduced a new tag, the form tag. This tag allowed to update ressources in addition to reading them via the tag. Being able to update ressources meant that we could really start building web applications. All of this with only two controls: and .
The process when submitting a form is quite similar to the anchor tag, except that we can:
- choose which kind of request we want to perform (GET or POST)
- attach user information like email, password, etc. to be passed with the request
The two tags are the only elements, in pure HTML, that can interact with a server.
And then came Javascript.
JavaScript was originally created to add simple interactions to web pages: form validation, data fetching, and basic animations. But with the introduction of XMLHttpRequest (later known as AJAX), JavaScript evolved into something much more powerful and complex.
With Javascript, developers could now trigger HTTP requests without the two tags, using something called AJAX. AJAX allows to fetch data from the server, and though XHR can fetch any type of data, including raw HTML fragments, text, or XML, JSON became the de facto data exchange format.
This means there needs to be an additional step where JSON gets converted to HTML, via a function that renders HTML from JSON. As shown in the example below, we proceed by:
- fetching JSON data from the
/api/usersendpoints (theresponse => response.json()part) - inserting this data into a HTML templates (the
const htmlpart) - that will then be added to the DOM (the
document.getElementById()part)
// The JavaScript way: JSON → HTML conversion
fetch('/api/users')
.then(response => response.json())
.then(users => {
const html = users.map(user =>
`${user.name}
`
).join('');
document.getElementById('users').innerHTML = html;
});This rendering involves a tight coupling between the JSON data format and the function itself: if the JSON data format changes, it could break the HTML rendering function. You already see one potential problem here, and this point is usually a friction point between frontend and backend developers: frontend dev builds a UI based on an expected JSON format, backend dev decides to change the format, frontend dev needs to update UI, backend dev changes again, frontend dev changes again, etc.
For some reason, web developers started putting JSON everywhere and managed everything with JS. This lead to what we call Single-Page Applications (SPAs): unlike traditional HTML 2.0, we don’t navigate between pages anymore. All the content stays on one page, and the content is updated with JS and UI rendering. This is how frameworks like React, Angular, Vue.js work.
“The emerging norm for web development is to build a React single-page application, with. server rendering. The two key elements of this architecture are something like:
– The main UI is built & updated in JavaScript using React or something similar.
– The backend is an API that that application makes requests against.
This idea has really swept the internet. It started with a few major popular websites and has crept into corners like marketing sites and blogs.”(Tom MacWright, https://macwright.com/2020/05/10/spa-fatigue)
Most current SPA architectures are “client-thick” applications where most of the job occurs on the client-side and where the backend is merely an API returning JSON. This setup is known for providing snappy and smooth user experiences, but do we really need that complexity every time?
“(…) there are also a lot of problems for which I can’t see any concrete benefit to using React. Those are things like blogs, shopping-cart-websites, mostly-CRUD-and-forms-websites.”
(Tom MacWright, https://macwright.com/2020/05/10/spa-fatigue)
Javascript Fatigue is real
The “Javascript fatigue” is getting louder. It refers to the main drawbacks of SPA development:
- Increasing complexity: Libraries and frameworks have become increasingly heavy and complex, requiring big teams to manage. Some opinionated frameworks also mean that JS developers have to specialize on one tech. No Python developer every called itself “A Tensorflow Python developer”. They’re just Python developers, and switching from TF to Pytorch still means you can read and use the two.
- Tight coupling: The coupling between data APIs and the UI creates friction within teams. Breaking changes occur everyday, and there is not way to solve this as long as teams use JSON as their exchange interface.
- Framework proliferation: The number of frameworks keeps increasing, leading to a real feeling of “fatigue” among JS developers.
- Over-engineering: You don’t need JS-heavy frameworks 90% of the time. And in some cases (content-heavy apps), it is even a bad idea.
Except for highly interactive/collaborative UIs, simple HTML with Multi-Page Applications is often enough.
So what is HTMX?
HTMX is a very lightweight JS library (14k) that offers a HTML-centric approach to building dynamic web applications. It extends HTML by allowing any element to make AJAX requests and update any part of the DOM. Unlike JS frameworks which do all the rendering on the client side, the heavy lifting is done by the server by returning HTML fragments to be inserted in the DOM. This also means that if you already know templating engines and HTML, the learning curve will be much much much easier compared to learning React or Angular.
Instead of abandoning hypermedia for JSON APIs, HTMX makes HTML more capable with the following:
- Any element can make HTTP requests (not just
and) - Any HTTP method (GET, POST, PUT, DELETE, PATCH)
- Any element can be targeted for updates
- Any event can trigger requests (click, submit, load, etc.)
In fact, you can actually write your own little GPT-like UI with HTMX and just a few lines of Python!
A real demo: a ChatGPT app with HTMX and FastAPI
For this article, we will build a little chat with less than 100 lines of Python and HTML. We will start with very simple demos to show how HTMX works, then add a simple chat UI, then add a streaming capability to our chat. To make things even more attractive, we will use the Google Agent Development Toolkit, so we can leverage agents in our chat!
Simple HTMX demos
Let’s assume we have an API that returns a list of users. We want to click a button to fetch the data and display a list.

The traditional, JS-way:
Demo
And this is how you would do with HTMX.
First create your backend:
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"{row['firstName']} {row['lastName']} \n"
return HTMLResponse(html)And then the HTML:
Demo
And you get exactly the same result! What happened just here? Look at the element. We see 3 attributes starting with hx-. What are they here for?
hx-get: Clicking on this button will trigger a GET request to the/usersendpointhx-target: It tells the browser to replace the content of the element which has theusersListid with the HTML data received from the serverhx-swap: It tells the browser to insert the HTML inside the target element
With that, you already know how to use HTMX. The beautiful thing about this way of doing is that if you decide changing your HTML, it won’t break anything on your page.
There are, of courses, advantages and drawbacks in using HTMX. But as a Python developer, it feels very nice to play around with my FastAPI backend and not worry a lot about rendering HTML. Just add Jinja templates, a dose of Tailwind CSS, and you’re good to go!
Our first chat with HTMX and FastAPI
So now is the moment when things are getting serious. What we will do, as a first step, is build a dumb chatbot that will take the users query, and spit it backwards. For that we will build a page with:
- a list of messages
- a textarea for the user’s input
And guess what, HTMX will take care of sending/receiving the messages! This is what the result will look like:

Overview
The flow is the following:
- User inputs a query in a textarea
- This textarea is wrapped in a form, which will send a POST request to the server with the
queryparameter. - The backend receives the request, does something with the
query(in real life, we can use a LLM to answer the query). In our case, for demo purposes, we will just reply by reverting the query letter by letter. - The backend wraps the response in an HTMLResponse (not JSON!)
- In our form, HTMX tells the browser where to insert the response, as shown in the
hx-target, and how to swap it with the current DOM
And this is all. So let’s begin!
Backend
We will define a /send route that expect a query string from the frontend, inverts it, and sends it back in a
from fastapi import FastAPI, Request, Form
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
import asyncio
import time
app = FastAPI()
templates = Jinja2Templates("templates")
@app.get("/")
async def root(request: Request):
return templates.TemplateResponse(request, "simple_chat_sync.html")
@app.post("/send")
async def send_message(request: Request, query: str=Form(...)):
message = "".join(list(query)[::-1])
html = f""
return HTMLResponse(html)Frontend
On the frontend side, we define a simple HTML page using Tailwind CSS and HTMX:
[email protected]&display=swap" rel="stylesheet">
// ZeChat
Let’s have a closer look the tag. This tag has several attributes, so let’s take a minute to review them:
hx-post="/send": It will make a POST request to the/sendendpoint.hx-trigger="click from:#submitButton": This means the request will be triggered when thesubmitButtonis clickedhx-target="#chat": This tells the browser where to put the HTML response. In that case, we want the response to be appended to the list.hx-swap="beforeend": The hx-target tells where to put the content, the hx-swap tells HOW. In that case, we want the content to be added before the end (so after the last child)
The hx-on::before-request is a little bit more complex, but can be explained easily. It basically happens between the click and the moment the request is sent. It will add the user input the bottom of the list, and clear the user input. This way, we get a snappy user experience!
A Better chat (streaming + LLM)
What we built is a very simple yet functional chat, however if we want to plug a LLM, we might have some times when the response from the server takes a long time. The way our current chat is built is synchronous, meaning nothing will happen until the LLM is finished writing. Not a great user experience.
What we need now is streaming, and a real LLM to have a conversation with. And this is Part 2.


