Do you remember how simple it was to write websites in the 90s and early 2000s? Do you reel back, horrified by the complexity of modern frontend development and yearn for those simpler times? Are you a Young Person to whom the current level of complexity seems normal, but a nagging voice in the back of your mind tells you there must be an Easier Way?I’ve just made a website with modern levels of interactivity using a new-ish frontend library called htmx, and it felt as straightforward as making a website did twenty years ago.


The short history of building websites goes something like this:

  • Ancient past: Write HTML by hand for each page.
  • Medieval times: Use a server-side templating engine to generate common elements and fill in repetitive structures.
  • Early modern times: Realise we can use the XMLHttpRequest() call to fetch data without reloading the page, leading to the kind of interactivity you find in sites like Google Maps.
  • Present: Go extremely hard on single-page-applications. Almost all the HTML is now generated by a front-end framework written in javascript (see React and others), and all the data is provided via JSON objects.

This happened partly because HTML is limited in how it can send HTTP requests. Only a <form> can POST, and a GET will reload the whole page. htmx asks the question “what if any HTML element could send any HTTP request, and could do so without triggering a page reload?”. What it gives you is an augmented HTML, capable of lots of things modern single-page-applications can do, without writing any javascript.

Types of interaction

The most obvious advantage of single-page-applications is their ability to load new content without doing a full page refresh. More specific types of interaction we might expect in a modern-feeling website that do not cause a full page reload are:

  • infinite scrolling lists
  • search and sort
  • create, read, update and delete user-generated content
  • giving feedback on long-running actions

I recently put htmx to the test by creating a website for organising your music-streaming-service album collection using as little javascript as possible while making it interactive and modern-feeling. Here are examples of each of the types of interaction listed above as implemented on the website. I’m using Django for the backend so that’s where the {% ... %} template syntax comes from.

Infinite scrolling lists

As you scroll the list of albums more will load automatically as you near the end. Here’s part of the template for the album list that shows how this works.

{% for user_album in user_albums %}
    <!-- code to render each album -->
{% endfor %}
{% if not final_page %}
        hx-get="/shelf/album-page/{{ }}?page={{ page|add:'1' }}&sortBy={{ sort_by }}&{% url_params %}"
        Loading more...
{% endif %}

When this <span> element is scrolled into view the intersect trigger is fired. This causes the element to perform an http GET to get the next page of albums from the server. It then replaces itself in the DOM with the response. The response uses this very template, so it includes a new version of the <span> targetting the next page.

Search and sort

We can achive a modern-style search where the results are updated as you type using the right triggers in htmx.

		id="search" type="search" name="search" placeholder="search"
		value="{% if search %}{{ search }}{% endif %}"
		hx-get="{% url 'shelf' %}"
		hx-trigger="search, keyup delay:400ms changed"

Here we’re saying that after a delay of 400ms, if the value of the <input> has changed, we will trigger a request to the server’s /shelf/[shelfid] endpoint, including the search query as a URL parameter. Sort works in a similar way, and both will refresh the albums shown by fetching the filtered list of albums from the server.

Create, read, update, delete

The purpose of the website is to allow users to organise their albums, so there needs to be a way for them to create, edit and delete shelves. A user can open the shelf menu, click “change colour”, choose a colour, then see it reflected in the shelf title:

Here is the code for the change colour button:

	hx-get="{% url 'colour_picker' %}"
	Change colour

This is fetching an HTML partial, in this case for the colour-picker component, and swapping it in where the button used to be using the outerHTML swap.

The HTML partial for the colour-picker, colour-picker.html, looks like this:

<div id="shelf-colour-picker" class="change-colour">
    {% for colour in colours %}
            class="shelf-colour-swatch bg-{{ colour }}"
            hx-put="{% url 'change_shelf_colour' colour %}"
    {% endfor %}

In this way it’s possible to get the benefits of something like a React component while skipping the layers in the middle where you fetch a JSON object then expand that data into the DOM using javascript. Here you simply fetch some templated HTML and insert it directly into the DOM.

Updating a long-running process

The example of the colour picker component is a bit narrow as it always appears in the same place in the shelf menu. Here’s another example of a reusable component that’s a bit more complex and is used in several different places on the website. It’s present in the header bar, on a shelf with no albums, and in the menu of the mobile version.

Here’s what it looks like in-progress. Fetching albums from the API can be a long process, I have around 1,000 saved albums and it takes around twenty seconds.

Here’s what the HTML for the progress element looks like:

    hx-get="{% url 'fetch_progress' fetch_hash %}"
    hx-trigger="load delay:2s"
    hx-target="closest .fetch-album-container"
    <label for="fetch-album-progress">Fetching albums:</label>
    <progress class="fetch-album-progress" max="100" value="{{ progress }}">
        {{ progress }}%

Here we’re saying GET the progress of the current fetch request every two seconds. The server endpoint will either return this HTML fragment filled in with the current progress if the job is ongoing, or the original fetch button if the job has finished.

Due to the hx-target being a bit more relaxed on this component – it replaces the nearest DOM element with the fetch-album-container class rather than a particular element based on its ID – we can put it in several different places on the page and they will all work independently.



The components of your site, like the colour-picker and progress bar demonstrated above, are already in the language of the web, HTML. When combined with a backend like flask or Django which use server-side templating these components can be easily re-used throughout your site. This reduces cognitive load when writing and debugging, as there are fewer transformational steps between your database and the end result in the user’s browser.

Easy to pick up

If you already know how to write websites using HTML and CSS, there’s not a huge leap requried to integrate htmx compared to something like React. React requires learning a new and complex mental model and can be difficult to debug in my experience. Debugging htmx feels more “on the surface” as you’re able to use the browser’s built-in debugger to see exactly what requests and responses were sent and received. The trickiest thing you’ll typically need to keep in mind is which headers were set and what is or is not being cached.


Not a silver bullet

You probably wouldn’t want to make an intricately interactive site like Google Sheets using htmx. But for many types of websites the kinds of interactivity afforded by htmx are enough.


Loading the sub-menus by doing network requests isn’t very efficient, as these could be loaded in on first load and shown using javascript. It would probably be possible to make the infinite-scroll experience a bit more user-friendly using more javascript. There’s currently no way to save your place after loading multiple pages of results, and navigating away then back will wipe out the loaded albums forcing you to scroll again to load them.

I deliberately went as hard as possible on htmx features for this project as a learning experience. Javascript is used only for showing and hiding the album details modal, and showing and hiding the navigation bar on mobile.

Caching HTML partials

One interesting bug I encountered during development was occasionally it seemed like the CSS and javascript was failing to load, but only when I had left the tab open for a long time. I managed to reproduce the bug 100% by doing a search, closing the tab, then re-opening it using the ctrl-shift-t shortcut.

Searching calls the shelf/[shelf-id] endpoint. This endpoint is capable of returning in several different ways. If you paste in a URL, it will render index.html. If you typed something in the search box, it will render the shelf-layout.html partial. This only contains the main content of the site – the shelf header and the list of albums, as this is the minimum we need to refresh on a search.

The way htmx allows us to distinguish between these different cases is by using request headers. In the case of typing something into the search box Hx-Trigger-Name will be “search”. The bug happens because this partial response is cached by the browser and reloaded when the tab is restored. The fix is to add Vary: Hx-Trigger to the partial response to make sure it doesn’t serve up the cached response when we don’t want it to.


Next time you make a website ask yourself a couple of questions:

  • How fancy does your website need to be really?
  • Can you get by with doing the kinds of DOM swaps that htmx enables, with a little javascript sprinkled on top to enhance the UI?


You may also like

{"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}

Get in touch

0 of 350