Digging Into HTMX: Examples and How to Use It

What is HTMX and why is the web developer community so excited about it? Check out this how-to article and find out! You will learn how to build modern user interfaces with simple hypertext.

HTMX aims to provide access to modern browser functionality directly in HTML code, without a single line of JavaScript. Even though version 1.0 of the library was launched just a few years ago, in late 2020, the project has already become incredibly popular. As of this writing, it has more than 20k stars on GitHub and has been accepted into the GitHub Open Source Accelerator!

Why is the web developer community so excited about it? Embark on this journey and find out! You will learn how to build modern user interfaces with simple hypertext.

What Is HTMX?

HTMX is a small, dependency-free, extendable library that allows you to access modern browser features directly from HTML, instead of using JavaScript. Specifically, it gives you access to AJAX (i.e., fetching content without reloading the whole page), CSS Transitions, WebSockets, and Server Sent Events directly via HTML attributes. The motivation behind the project is to overcome the limitations imposed by HTML to make it a true hypertext.

The easiest way to understand what we are talking about? An example! Take a look at this snippet:

<button hx-get="/api/v1/hello-world" hx-swap="outerHTML">Click Me</button>

The special hx-get and hx-swap attributes tell HTMX:

“When a user clicks on this button, instruct the browser to perform an AJAX request to the ‘/api/v1/hello-world’ endpoint, and replace the entire button with the HTML content returned by the server”

In JavaScript, achieving the same result would take dozens of lines of code. That is the power of HTMX!

How We Got to HTMX: From HTML to HTMX

Web development has evolved a lot since its early days. It started with static web pages, where manual updates to HTML files were the norm. The introduction of JavaScript added interactivity, opening the door to a new era. AJAX then completed the revolution, enabling seamless content updates and new interactions.

Over time, frameworks like React, Vue, and Angular took the stage and became the standard. These technologies are great for structured applications. However, they also involve a lot of complexity, and sometimes you want to keep things simple. That is why HTMX!

HTMX aims to achieve efficient interactivity without the complexity of traditional JavaScript setups. In particular, it extends HTML with custom attributes, allowing the execution of AJAX requests without JavaScript. It also integrates seamlessly with existing technology stacks to provide an improved user experience without a complete overhaul.

Let’s now explore the features and syntax of HTMX!

HTMX Overview: Syntax, Features, and Capabilities

The core idea behind HTMX is the ability to send AJAX requests directly from HTML, with no JavaScript involved. This is possible thanks to the following attributes:

  • hx-get: To perform a GET request to the given URL.
  • hx-post: To perform a POST request to the given URL.
  • hx-put: To perform a PUT request to the given URL.
  • hx-patch: To perform a PATCH request to the given URL.
  • hx-delete: To perform a DELETE request to the given URL.

When a specific event is triggered, the HTML element involving one of these HTMX attributes will make an AJAX request of the specified type to the given URL. Consider the example below:

<button hx-post="/api/v1/products/buy">Buy</button>

This tells the browser:

“When a user clicks on the <button>, make a POST request to the URL ‘/api/v1/products/buy’ and load the response into the inner HTML of the <button>

How can a single line of HTML result in that behavior? Well, the aspects to consider when making an AJAX request are three:

  1. When to perform the request
  2. The query parameters and/or body to send
  3. What to do with the response

Time to dig into how HTMX handles them!

Request Triggers

By default, AJAX requests made by HTMX are triggered by the “natural” event associated with the HTML element:

  • change: For <input>, <textarea> and <select> elements.
  • submit: For the <form> element.
  • click: For every other element.

Going back to the snippet seen earlier, it should now be clear why the action that triggers the request is a click, even if not specified.

To modify the default trigger behavior, you can use the hx-trigger attribute to set which HTML event will cause the request. Check out the list of events supported by HTML

Take a look at the example below:

<span hx-get="/api/v1/products" hx-trigger="mouseenter">Hover Me!</span>

This tells the browser:

"When a user moves the mouse over the <span>, perform a GET request to the URL ‘/api/v1/products’ and render the response in the inner HTML of the <span>

Keep in mind that hx-trigger also supports modifiers and filters to tailor the triggering logic to your needs. Plus, HTMX provides the following special events:

  • load: Fires when the element is loaded for the first time.
  • revealed: Fires once when the element is scrolled into the viewport.
  • intersect: Fires once when the element intersects with the viewport. As opposed to revealed, it accepts an optional CSS selector of the root element for intersection and a float number between 0.0 and 1.0 to indicate the amount of intersection to trigger the event on.

Query Parameters and Body Data

The way HTMX handles parameters and body data changes depending on the type of the request:

  • GET requests: The query parameters should be specified in the URL passed to hx-get. By default, hx-get does not automatically include any parameters to the request. Anyway, you can control that with the hx-params attribute as explained in the documentation.
  • Non-GET requests: If an element is a <form>, the body will include the values of all inputs within it, using their name attribute as the parameter name. If it is not a <form>, the body will include the values of all the inputs of the nearest enclosing <form>. Otherwise, if it has a value attribute, it will be used in the body. When the default behavior is not enough, the hx-include and hx-params attributes allows you to control which values and which parameters to set, respectively. Otherwise, you can programmatically modify the body fields by listening to the htmx:configRequest event.

Result Content Handling

By default, HTMX replaces the inner HTML of the element firing the request with the HTML returned by the AJAX call. This means that HTMX-compliant AJAX endpoints should return HTML code.

To change the swap strategy, use the hx-swap attribute. That supports the following values:

  • innerHTML: Replace the inner HTML of the target element.
  • outerHTML: Replace the entire target element with the response.
  • beforebegin: Insert the response before the target element.
  • afterbegin: Insert the response before the first child of the target element.
  • beforeend: Insert the response after the last child of the target element.
  • afterend: Insert the response after the target element.
  • delete: Delete the target element regardless of the response.
  • none: Does not append the content from the response.

You can change the target element the swap logic refers to with the hx-target attribute, which accepts a CSS selector. Note that the attribute supports multiple triggers, each one separated by comma.

Focus now on the following snippet:

<button 
  hx-post="/api/v1/comments"
  hx-trigger="click" 
  hx-swap=".comments" 
  hx-target="afterend"
  >
Comment
</button>

This tells the browser:

“When a user clicks the <button>, perform a POST request to the URL ‘/api/v1/comments’ and add the resulting HTML to the .comments element”

HTMX in Action: Integration With ApostropheCMS

You now know what HTMX is, why it was created, and what it brings to the table. All that remains is to see it in action in a real-world example. What better way to do that than by integrating it with Apostrophe? If you are not familiar with this technology, ApostropheCMS is an open-source CMS and website builder built on top of modern technologies such as MongoDB and Node.js. 

As ApostropheCMS is unopinionated on the frontend, it represents a natural fit for HTMX. Its data management and website building capabilities will make it easy and intuitive to dynamically retrieve and render HTML content via HTMX. 

In this step-by-step section, you will look at how to integrate HTMX into an existing application. The starting point will be the blog application built in the “How to Build a Blog with the Apostrophe Blog Module” tutorial. You will learn how to add HTMX and use it to achieve the following dynamic interactions:

  • “Load More” functionality
  • Infinite scroll loading
  • Live content filtering 

Let’s dive in!

Getting Started

First, make sure to meet ApostropheCMS's system requirements. Next, launch the command below to clone the GitHub repository of the blog application you will soon extend with HTMX:

git clone https://github.com/Tonel/apostrophe-blog

Install the project’s dependencies:

npm install

Then, fire the following command to build the ApostropheCMS UI and start up the blog:

npm run dev

Open https://localhost:3000 in the browser and you should see:

HTMX Apostrophe UI

Follow the instructions, log in, and get familiar with the application. Play with the UI and populate the blog with several posts.

You can then find the blog's home page at http://localhost:3000/blog.

htmx blog example

Great! If you want to learn more about how this application works and was built, take a look at our tutorial.

Integrating HTMX

As stated in the documentation, integrating HTMX into an application boils down to adding a <script> tag to the document <head>. No build tools or special configurations are required.

The fastest way to get going is to load the library via a CDN:

<script src="https://unpkg.com/htmx.org@1.9.6" integrity="sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni" crossorigin="anonymous"></script>

The goal of this section is to use HTMX to add dynamic interactions to the blog home page. So, you need to add the <script> instruction to the HTML document of that page.

To add HTMX to the blog home page, follow the /modules/@apostrophecms/blog-page/views/ path and open the index.html file. Paste the following line after the title block:

{% block extraHead %}
  <script src="https://unpkg.com/htmx.org@1.9.6" integrity="sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni" crossorigin="anonymous"></script>
{% endblock %}

extraHead is a block from the ApostropheCMS core layout template that allows you to add HTML elements at the end of the <head> tag.

If you instead want to have HTMX in all pages, you can add it to your project's dependencies with:

npm install htmx.org

Then, import it in the modules/asset/ui/src/index.js file:

import 'htmx.org';

export default () => {
  // your own project-level JS...
};

Open http://localhost:3000/blog in the browser and inspect its source code. You should see the following HTML:

<!DOCTYPE html>
<html lang="en" >
  <head>
    <link href="/apos-frontend/default/apos-bundle.css" rel="stylesheet" />
    <title>My Fantastic Blog </title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <script src="https://unpkg.com/htmx.org@1.9.6" integrity="sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni" crossorigin="anonymous"></script>
  </head>
<!-- Omitted for brevity... -->

Well done! The HTMX dependency script was added as required.

Adding a Load More Button With HTMX

Right now, when the blog has more than 10 posts, the home page shows a pagination element. 

htmx blog index

Click on one of these buttons, and you will be redirected to the selected page. For example, “2” brings you to /blog?page=2. What if you wanted to replace that interaction with a “Load More” button? Thanks to HTMX, that will take only a few lines of code!

When the “Load More” button is clicked, the page should perform an AJAX request to retrieve the HTML to render other blog post cards. You could think of using HTMX to make the button target the /blog?page=2 endpoint and swap the current content with the retrieved HTML. However, keep in mind that /blog?page=2 returns the entire HTML of a new page. Following this approach is not recommended, as you ideally want to replace only a small portion of the page not the all the page. Specifically, you want to swap the “Load More” button with the new blog post tabs.

To get close to the goal, you can take advantage of the aposRefresh=1 parameter. This query parameter instructs ApostropheCMS to return the rendered HTML of the inner template, excluding the wrapping markup.

For example, the /blog?page=2&aposRefresh=1 endpoint returns something like:

<div class="bg-container">
  <h1 class="bg-h1">My Fantastic Blog</h1>
  <h2>Filters</h2>
  <ul class="bg-filter-list">
    <li>
      <a href="/blog?year=2023">2023</a>
    </li>
    <li>
      <a href="/blog?year=2022">2022</a>
    </li>
    <li>
      <a href="/blog?year=2021">2021</a>
    </li>
    <li>
      <a class="is-active" href="/blog">All</a>
    </li>
  </ul>
  <h2>Blog post</h2>
  <div class="bg-preview-card">
    <div class="bg-preview-date">
      Released on September 4, 2022
    </div>
    <div class="bg-preview-title">
      <a href="/blog/lorem-ipsum-8">Lorem Ipsum 8</a>
    </div>
  </div>
  <div class="bg-preview-card">
    <div class="bg-preview-date">
      Released on August 2, 2022
    </div>
    <div class="bg-preview-title">
      <a href="/blog/lorem-ipsum-12">Lorem Ipsum 12</a>
    </div>
  </div>
  <!-- Omitted for brevity... -->
</div>

Much better! As you can see, this HTML involves only a partial section of the page. At the same time, it still includes the title and filter elements. To ignore them, you can change the index.html file so that it behaves differently based on the presence of a custom query parameter.

Achieve that by updating the rendering logic inside the main block of /modules/@apostrophecms/blog-page/views/index.html as follows: 

{% block main %}
  {% if data.query.showOnlyList != "1" %}
    <div class="bg-container">
      <h1 class="bg-h1">{{ data.page.title }}</h1>
      <h2>{{ __t('aposBlog:filters') }}</h2>

      {% render filters.render({
           filters: data.piecesFilters,
           query: data.query,
           url: data.page._url
      }) %}

      <h2>{{ __t('aposBlog:pluralLabel') }}</h2>

      {{ renderBlogList() }}
    </div>
  {% else %}
    {{ renderBlogList() }}
  {% endif %}
{% endblock %}

Now, when the showOnlyList=1 query parameter is present, the endpoint for the blog home page will return only the list of blog posts. Otherwise, it will return the entire page as before.  

You may be wondering what renderBlogList() is. This is a custom Nunjucks macro that renders the list of blog post cards and the “Load More” button:

{% set page = data.query.page | default(1) | int %}
  {% for piece in data.pieces %}
    <div class="bg-preview-card">
      <div class="bg-preview-date">
        {{ __t('aposBlog:releasedOn') }} {{ piece.publishedAt | date('MMMM D, YYYY') }}
      </div>
      <div class="bg-preview-title">
        <a href="{{ piece._url }}">{{ piece.title }}</a>
      </div>
    </div>
  {% endfor %}
  <div class="load-more-div">
    {% if page != data.totalPages %}
      <button
        hx-get="/blog?page={{page + 1}}&year={{data.query.year}}&showOnlyList=1&aposRefresh=1"
        hx-target=".load-more-div"
        hx-swap="outerHTML"
      >
        Load More
      </button>
    {% endif %}
  </div>
{% endmacro %}

Focus on the .load-more-div HTML element. That is where the HTMX magic happens!

page is a variable that stores the current page of the blog posts to render. If there are still some blogs to load, the “Load More” button is added to the page. When the user clicks it, the webpage makes an AJAX request to the endpoint specified in hx-get. This will return the rendered HTML with the list of the posts related to the next page, considering the optional year filter. HTMX will then replace the outer HTML of the .load-more-div element with that content.

Put it all together, and you will get:

<!-- /modules/@apostrophecms/blog-page/views/index.html -->

{% extends data.outerLayout %}

{% import "filters.html" as filters %}
{% import "@apostrophecms/pager:macros.html" as pager with context %}

{% block title %}{{ data.page.title }} {% endblock %}

{% block extraHead %}
  <script src="https://unpkg.com/htmx.org@1.9.6" integrity="sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni" crossorigin="anonymous"></script>
{% endblock %}

{% macro renderBlogList() %}
  {% set page = data.query.page | default(1) | int %}
  {% for piece in data.pieces %}
    <div class="bg-preview-card">
      <div class="bg-preview-date">
        {{ __t('aposBlog:releasedOn') }} {{ piece.publishedAt | date('MMMM D, YYYY') }}
      </div>
      <div class="bg-preview-title">
        <a href="{{ piece._url }}">{{ piece.title }}</a>
      </div>
    </div>
  {% endfor %}
  <div class="load-more-div">
    {% if page != data.totalPages %}
      <button
        hx-get="/blog?page={{page + 1}}&year={{data.query.year}}&showOnlyList=1&aposRefresh=1"
        hx-target=".load-more-div"
        hx-swap="outerHTML"
      >
        Load More
      </button>
    {% endif %}
  </div>
{% endmacro %}

{% block main %}
  {% if data.query.showOnlyList != "1" %}
    <div class="bg-container">
      <h1 class="bg-h1">{{ data.page.title }}</h1>
      <h2>{{ __t('aposBlog:filters') }}</h2>

      {% render filters.render({
           filters: data.piecesFilters,
           query: data.query,
           url: data.page._url
      }) %}

      <h2>{{ __t('aposBlog:pluralLabel') }}</h2>

      {{ renderBlogList() }}
    </div>
  {% else %}
    {{ renderBlogList() }}
  {% endif %}
{% endblock %}

Note that the pagination element has been removed by the template. You no longer need it.

Style the “Load More” button in /modules/asset/ui/src/scss/_blog.scss, and you are ready to test it. This is how your new http://localhost:3000/blog page behaves:

If you inspect the “Network” section of the browser's DevTools, you will notice that the “Load More” button triggers the following AJAX call:

htmx dev tools

This will return the HTML containing the new blog post cards to add to the page.

Congrats! You just used HTMX to add a click-to-load feature to your blog.

Note: You can find the entire code of this example in the htmx-load-more branch of the GitHub repository supporting the article.

Using HTMX to Implement Infinite Scrolling

Now that you have seen how to implement a “Load More” button with HTMX, achieving infinite scrolling is easy. All you have to do is change the renderBlogList() function as follows:

{% macro renderBlogList() %}
  {% set page = data.query.page | default(1) | int %}
  {% for piece in data.pieces %}
    <div class="bg-preview-card"
       {% if loop.last %}
           hx-get="/blog?page={{page + 1}}&year={{data.query.year}}&showOnlyList=1&aposRefresh=1"
           hx-trigger="revealed"
           hx-swap="afterend"
       {% endif %}
    >
      <div class="bg-preview-date">
        {{ __t('aposBlog:releasedOn') }} {{ piece.publishedAt | date('MMMM D, YYYY') }}
      </div>
      <div class="bg-preview-title">
        <a href="{{ piece._url }}">{{ piece.title }}</a>
      </div>
    </div>
  {% endfor %}
{% endmacro %}

The revealed HTMX event triggers when an element is scrolled into the viewport. By adding it to the last blog post card, you can implement infinite scroll loading behavior:

Awesome! Focus on the scrollbar to notice that the page adds dynamic content as the user scrolls down.

Note: You can find the complete code of this example in the htmx-infinite-scrolling branch.

Achieving Live Content Filtering Through HTMX

The goal here is to use HTMX to dynamically update the content of the page when clicking on a year filter button. In this case, you do not have to update the blog post list, but replace it entirely. Also, you need to override the HTML section that contains the filters to ensure that the correct button is enabled.

First, update filters.html to introduce the HTMX logic:

<!-- /modules/@apostrophecms/blog-page/views/filters.html -->

{%- macro here(url, changes) -%}
{{ url | build({
year: data.query.year
}, {
excludeContainer: 1,
aposRefresh: 1
}, changes) }}
{%- endmacro -%}

{% fragment render(data) %}
  <ul class="bg-filter-list">
    {% for year in data.filters.year %}
      <li>
          <button
            class="{{ 'is-active' if data.query.year == year.value }}"
            hx-get="{{ here(data.url, { year: year.value })}}"
            hx-target=".blog-page"
            hx-swap="outerHTML"
          >
            {{ __t(year.label) }}
          </button>
      </li>
    {% endfor %}
</ul>
{% endfragment %}

Note that the year filter elements are no longer links, but buttons that target a specific endpoint via HTMX. In particular, here() has been updated to produce the URL required to dynamically retrieve the desired HTML content. Keep in mind that build() accepts as many query parameter objects as you need. 

The excludeContainer query parameter will control the rendering logic in the index.html file as below:

<!-- /modules/@apostrophecms/blog-page/views/filters.html -->

{% extends data.outerLayout %}

{% import "filters.html" as filters %}
{% import "@apostrophecms/pager:macros.html" as pager with context %}

{% block title %}{{ data.page.title }} {% endblock %}

{% block extraHead %}
  <script src="https://unpkg.com/htmx.org@1.9.6" integrity="sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni" crossorigin="anonymous"></script>
{% endblock %}

{% block main %}
  {% if data.query.excludeContainer != "1" %}
    <div class="bg-container">
      <h1 class="bg-h1">{{ data.page.title }}</h1>
  {% endif %}
     <div class="blog-page">
        <h2>{{ __t('aposBlog:filters') }}</h2>
        {% render filters.render({
             filters: data.piecesFilters,
             query: data.query,
             url: data.page._url
        }) %}
        <h2>{{ __t('aposBlog:pluralLabel') }}</h2>
        {% for piece in data.pieces %}
          <div class="bg-preview-card">
            <div class="bg-preview-date">
              {{ __t('aposBlog:releasedOn') }} {{ piece.publishedAt | date('MMMM D, YYYY') }}
            </div>
            <div class="bg-preview-title">
              <a href="{{ piece._url }}">{{ piece.title }}</a>
            </div>
          </div>
        {% endfor %}
        <div class="pagination">
          {{ pager.render({ page: data.currentPage, total: data.totalPages }, data.url | build({ excludeContainer: null })) }}
        </div>
      </div>
  {% if data.query.excludeContainer != "1" %}
    </div>
  {% endif %}
{% endblock %}

The home page of the blog will now have real-time filtering capabilities:

Awesome! You have just learned how HTMX simplifies the integration of dynamic interactions into an existing frontend application.

The next step is to add a loader through the hx-indicator attribute. Check out the docs to see all the other cool features HTMX has to offer!

Note: You can find the entire code of the example in the htmx-content-filtering branch.

Is HTMX the Future of Web Development?

HTMX is not here to replace React and similar frameworks. That should be clear. Instead, it presents a compelling solution for specific web development scenarios backed by its feature set. 

To be specific, HTMX proves particularly well-suited for:

  • Efficient interactivity: It represents a lightweight solution compared to most JavaScript frameworks, making it an excellent choice to keep projects simple and lean.
  • Seamless integration: It natively supports integration with various technologies, such as ApostropheCMS, Django, and Flask.
  • Minimal overhead: It simplifies web development via custom HTML attributes, removing the complexity associated with modern JavaScript development.

In short, HTMX fills a niche where its capabilities shine, providing a valuable tool for web developers seeking efficient and seamless interactivity.

Conclusion

In this article, you explored HTMX, understanding what the library is, why it was born, what capabilities it provides, and why it is so popular. You learned how to get started with HTMX and how to use it to extend an existing ApostropheCMS application with dynamic interactions such as infinite scrolling. Once again, ApostropheCMS has proven to be a modern, solid, forward-looking technology that can support fresh libraries thanks to its unopinionated approach on the frontend. Try Apostrophe today!