There’s been a trend of developers discovering multi-page applications (MPA) again lately, this time with the help of some JavaScript libraries that add interactivity without having to create a single-page application (SPA).

One of these is htmx, which is being discussed quite a bit lately and it seems it’s becoming more popular.

I’ve personally tried htmx for one of my projects but soon replaced it with Unpoly. Unpoly works in a similar way, but in my view (not only mine, actually) it provides an overall better experience than htmx.

NOTE: this article doesn’t pretend and doesn’t want to be a full and detailed comparison between htmx and Unpoly. It’s more like a list of reasons why I personally prefer Unpoly, so maybe you can consider it too.

Progressive enhancement

In htmx, it’s common to see buttons or other elements with interactive behavior attached to it:

<button hx-get="/contacts">Reload</button>
<a hx-get="/contacts">Reload</a>

I see two problems with that:

  1. if htmx fails to load for some reason, those buttons and links won’t do anything
  2. in both cases, those links are not actual links: you cannot open them in a new tab, for example

So I tend to prefer Unpoly’s approach where you first construct your navigation with normal links and forms/button, and then enable interactivity with up-* attributes:

<form action="/contacts" up-submit>
    <button>Reload</button>
</form>
<a href="/contacts" up-follow>Reload</a>

I know, there are ways to achieve this in htmx too. It’s just that the examples I’ve seen in the wild don’t really seem to be careful enough about this, while with Unpoly it’s the default way of doing it.

Layers

If you want to implement a complex modal with htmx, well, good luck with that.

Unpoly has built-in support for layers (i.e. modals, drawers, popups, or overlays in general). They can even be stacked and they work well with the history API, out of the box.

It’s really this easy:

<a href="/users/create" up-layer="new">Open modal</a>

But it also allows to customize pretty much everything of it (including animations).

Layers by default stack on top of each other (new), allowing for sub-interactions, but can also swap the currently open layer (swap) or replace all the existing layers (shatter).

You can also choose in which ways the user will be able to dismiss the layer: with an “X” button, by clicking outside the layer, or with the escape key.

Error handling and form validation

Unpoly allows for much easier and advanced failed requests and network issues handling. Most of the time, it works just fine out of the box.

In htmx, responses with status codes other than 2xx are ignored by default and can only be processed through custom JavaScript.

In Unpoly, this makes it easier to implement form validation, since 422 HTTP responses are processed as you would expect.

JavaScript API

In Unpoly, anything you can do with HTML attributes can also be done through the JavaScript API.

As a practical example, you can reload a list of items after a creation modal is closed successfully:

<a href="/contacts/create" up-layer="new" up-on-accepted="up.reload('#list')">

Caching

Unpoly includes support for caching and it’s enabled out of the box.

This is how it works: by default, Unpoly doesn’t request the same URL again if it was requested less than 15 seconds earlier (only for GET requests), also invalidating the cache when non-GET requests are sent.

You can customize pretty much every aspect of it, both globally or per-request.

Preloading

Caching in Unpoly also makes it possible to implement preloading of requests/pages in a proper way.

While in htmx preload never actually uses the preloaded request and instead just sends a new one on the actual action, with Unpoly it works out of the box as you would expect.

In htmx, this will load the URL twice:

<a href="/contacts/create" preload>

Unpoly will instead use the preload request (which might still be in progress) when the link is clicked:

<a href="/contacts/create" up-follow up-preload>

Compilers

I don’t use this feature myself (I prefer Stimulus), but Unpoly includes a way to execute custom JavaScript code attached to HTML elements.

The library takes care of monitoring new elements that are rendered later and also provides a way to do cleanup when the elements are destroyed.

Here’s an example from the documentation:

up.compiler('.current-time', function(element) {
  let update = () => element.textContent = new Date().toString();
  setInterval(update, 1000);
  return () => clearInterval(update);
});

Loading indicator

Unpoly comes with a loading indicator at the top of the page (GitHub-like) on slow requests, out of the box. You can customize what a slow request means, or also disable the default loading indicator and implement your own.

I’ve personally replaced it with topbar because it feels smoother.

Macros

If you happen to use a common set of up-* attributes together often, you can define a macro, which is basically a new attribute that triggers a special compiler. In the compiler you can then add your attributes, or actually do anything with the HTML element.

Bundlers

Unpoly works well out of the box with npm and JavaScript bundlers, like Vite.

On the other hand, htmx sometimes has issues and there’s a discussion on whether htmx should even include out of the box support for those scenarios.

Bonus: documentation

Unpoly documentation is generally very good, well-structured and quite detailed. It also includes full-text search, which is often useful.


This being said, Unpoly isn’t obviously perfect: it’s (understandably) a heavier library, error handling isn’t perfect and it lacks TypeScript support.

Do you use htmx or Unpoly? What do you think?