htmx in Context: HTML over the Wire Instead of JSON, and When That Is the Better Choice

I built the same event calendar with htmx, with Unpoly and with no library at all: code, measurements, pitfalls and an honest guide to choosing.

by ·

What do I actually think of htmx (opens in a new tab)? Good, bad, outdated? To be honest, for a long time all I had was an opinion, not experience. I have been working with websites for more than 30 years, but I had never used htmx in a project. An assessment without my own code would only be a summary of what other people have written.

So for this series I built something: a small, fictional event calendar, once with htmx, once with Unpoly (opens in a new tab) and once with no library at all. A fourth, deliberately fragile htmx build comes on top. The demo runs live at /en/demo/event-calendar/, and you can click through everything in this article yourself.

This first part puts things in context: what htmx actually is, how the same feature looks in the three builds, where I stumbled while building them and when I would use htmx today. Part 2 measures what search engines and AI crawlers get to see of content loaded after the fact. Part 3 checks what swapping HTML costs in accessibility.

A note on method: I checked versions, licences and browser support against primary sources: the npm registry, the official documentation, the source code of the pinned versions and MDN. File sizes and the behaviour under the Content Security Policy I measured myself, on 29 September 2026, against the demo as served live. Any judgement that goes beyond this demo is marked as my assessment.

The idea: the server sends HTML, not JSON

Most modern web interfaces follow one pattern. The server delivers data as JSON, and a JavaScript framework in the browser builds the HTML from it. That puts the logic in two places: validation, state and often routing exist once on the server and once in the client.

htmx turns this back around. The server sends finished HTML, and htmx uses it to swap parts of the page. You control it through attributes right in the markup:

<a href="/calendar/workshops/" hx-get="/calendar/workshops/" hx-target="#list">Workshops</a>

A click fetches the address via Ajax, and the response replaces the content of #list. No JSON, no template in the browser, no build step. The project calls this approach hypermedia: HTML is not just presentation, it also carries the possible next steps in the form of links and forms. The full argument is in the essays on htmx.org (opens in a new tab) and in the freely readable book “Hypermedia Systems” (opens in a new tab) by Carson Gross, Adam Stepinski and Deniz Akşimşek from 2023.

The hard facts about htmx itself:

  • Size: the project states “~16k min.gz'd”. Measured: htmx.min.js of version 2.0.11 is 52,182 bytes, 16,838 bytes compressed with gzip (level 9).
  • Dependencies: none. A <script> tag is enough; you don't need a build step.
  • Licence: Zero-Clause BSD (0BSD), even more permissive than MIT, it does not even require a licence notice.
  • Version: on npm, 2.0.11 is the current version (latest). htmx 4.0.0 was released as final on 28 August 2026, but deliberately sits only under the next tag. The project says it will make 4.0 latest at some point in 2027, so that existing 2.x projects are not upgraded by accident.

htmx 4 is more than a maintenance update. According to the release announcement (opens in a new tab) and “What's New” (opens in a new tab), it uses fetch() internally instead of XMLHttpRequest, and attributes only inherit explicitly via the :inherited suffix. Events have new names (such as htmx:before:request), and the history cache in browser storage is gone. I built the demo with 2.0.11 because that is what npm install htmx.org installs today. Where behaviour differs between the versions, I say so.

The demo: one event calendar, built four times

The demo shows twelve events of a made-up town library in three categories. The content is deliberately dull so the technology stays in focus. Each feature stands for a typical htmx pattern:

# Feature robust build fragile build
1 List → detail page real links, hx-boost –
2 Filter tabs link to a real category URL button that loads a fragment without its own URL
3 Load more link to page 2, enhanced by htmx infinite scroll with hx-trigger="revealed"
4 “Upcoming events” box in the HTML fetched after load (hx-trigger="load")
5 Quick info as a dialog <dialog> fragment swapped into a <div>

Robust means: every link reaches its target without JavaScript too; htmx only makes it faster. Fragile means: without JavaScript the content is missing. Both look identical in the browser, and that is what Parts 2 and 3 are about.

The robust build exists three times with identical content: with htmx, with Unpoly and with no library. The fragile build exists only with htmx. Eleventy generates all pages and fragments from a single data file. There is no server assembling responses, only pre-rendered HTML files. I serve both libraries myself, pinned to exact versions and without a CDN. The demo runs under the same Content Security Policy as the rest of this site, and it does not allow 'unsafe-eval'.

The same with htmx

The most important line sits at the very top:

<body hx-boost="true">

hx-boost turns every normal link and every form inside <body> into an Ajax request. The response replaces the page content without the browser reloading, and the URL in the address bar is still updated. The links stay real links. Without JavaScript the page works just the same, only with a full reload.

For the filter tabs, boosting alone is not enough, because only the catalogue should be swapped, not the whole page:

<a href="/db/en/demo/event-calendar/htmx/workshops/"
   hx-get="/db/en/demo/event-calendar/htmx/workshops/"
   hx-select="#katalog" hx-target="#katalog" hx-swap="outerHTML"
   hx-push-url="true">Workshop</a>

The link points to a real page that lists all workshops. htmx fetches exactly that page, cuts out the #katalog area with hx-select and replaces the current catalogue with it. hx-push-url writes the new address into the history, so the back button and bookmarks work. I don't need a separate fragment for the tab. The same URL serves as a full page and as the source of the excerpt. (The IDs in the demo's markup are German: katalog is the catalogue, liste the list, mehr “more”.)

“Load more” works on the same principle, except that it appends instead of replacing:

html
<p id="mehr">
  <a href="/db/en/demo/event-calendar/htmx/page/2/"
     hx-get="/db/en/demo/event-calendar/htmx/page/2/"
     hx-select="#liste > li" hx-target="#liste" hx-swap="beforeend"
     hx-select-oob="#mehr">Load more</a>
</p>

I looked up two details in the source code of htmx 2.0.11. First, hx-select takes every match of a selector (internally querySelectorAll), here all list entries of page 2. Second, hx-select-oob also swaps the #mehr paragraph for the version from page 2, which says “All events loaded.” In htmx 2, hx-select-oob only evaluates IDs, not arbitrary CSS selectors.

For the quick info, htmx loads an event's detail page, cuts out the article and puts it into a <dialog>. Five lines of my own JavaScript open it:

document.addEventListener("htmx:afterSwap", (event) => {
	if (event.detail?.target?.id !== "dialog-inhalt") return;
	const dialog = document.getElementById("kurzinfo-dialog");
	if (dialog && !dialog.open) dialog.showModal();
});

The ?. in the second line is not there out of caution. Without it my script threw an error; more on that under the pitfalls.

The same with Unpoly

Unpoly follows the same idea as htmx but makes different default choices. It comes from Henning Koch at the agency makandra, is MIT-licensed and also has no dependencies. The pinned version is 3.14.3.

The counterpart to hx-boost is one line of configuration:

up.link.config.followSelectors.push("a[href]");

After that, Unpoly follows every link itself. The only exceptions are links where that makes no sense, such as downloads, links with target, mailto: or addresses on other domains. The tabs and “Load more” look like this:

<a href="/db/en/demo/event-calendar/unpoly/workshops/"
   up-target="#katalog" up-history="true">Workshop</a>

<a href="/db/en/demo/event-calendar/unpoly/page/2/"
   up-target="#liste:after, #mehr">Load more</a>

#liste:after appends the children of the new #liste to the existing one, and #mehr is replaced in the same step. The tab needs up-history="true" because Unpoly on its own only changes the history when the main area of the page is swapped, which is not the case here.

The quick info is a one-liner in Unpoly, because overlays come with the package:

<a href="/db/en/demo/event-calendar/unpoly/event/e01/"
   up-layer="new modal" up-target="#detail">Quick info</a>

The overlay brings its role, focus handling and closing via Escape with it. Part 3 shows how big the difference from htmx is at this point.

The price is size. unpoly.min.js is 179,176 bytes, 58,842 bytes with gzip. That is just under three and a half times htmx. On top comes a stylesheet of 1,130 bytes (gzip). In return you get more ready-made building blocks and less code of your own.

The same with no library

The third build needs no JavaScript of my own at all. Tabs and “Load more” are ordinary links to real pages. Every click loads a new page, and that is exactly the point: many interfaces do not need partial swaps at all.

To keep the page changes from feeling like a hard cut, I use cross-document view transitions:

@media (prefers-reduced-motion: no-preference) {
	@view-transition {
		navigation: auto;
	}
}

The browser then cross-fades from the old page to the new one. The media query makes sure this only happens if you have not asked your operating system for reduced motion. Chrome and Edge support this from version 126, Safari from 18.2. Firefox still cannot do cross-document view transitions and simply loads the page normally. That does no harm, because the page works anyway. MDN accordingly does not list the feature as Baseline.

The quick info is a native <dialog>, opened without JavaScript through invoker commands:

html
<button type="button" commandfor="kurzinfo-e01" command="show-modal">Quick info</button>
<dialog id="kurzinfo-e01" aria-labelledby="kurzinfo-e01-titel">
	<h2 id="kurzinfo-e01-titel">Crime Night with a Local Author</h2>
	…
	<button type="button" commandfor="kurzinfo-e01" command="close">Close</button>
</dialog>

<dialog> with showModal() has been “Baseline widely available” since September 2024. The commandfor and command attributes are younger: Chrome and Edge from 135, Firefox from 144, Safari from 26.2. They have been Baseline since 12 December 2025, but not yet “widely available”. The platform toolbox also includes the popover (opens in a new tab) attribute (Baseline since April 2024), which the demo does not need.

What is missing without a library is the partial swap. “Load more” here is a switch to page 2, not an append to the existing list. And because the browser cannot fetch the quick info later, all six dialogs sit complete in the list's HTML. With six events that does not matter; with six hundred it does.

Turbo, Datastar and fixi in brief

Three more candidates I did not build live, but placed based on their primary sources:

Turbo Datastar fixi
Version (29 Sep 2026) 8.0.23 1.0.4 (GitHub release; the npm package is stale) 0.9.4
Licence MIT MIT 0BSD
Origin 37signals, reference integration Ruby on Rails Star Federation (non-profit) Big Sky Software, Carson Gross (also htmx)
Idea Drive (page changes), Frames (regions), Streams (server updates) reactive signals in the browser plus server actions, signature feature server-sent events htmx reduced to the minimum: 90 lines, 1,473 bytes gzip

Turbo (opens in a new tab) comes from the Rails world but does not require Rails. The equivalent of my tab switch is a frame:

<turbo-frame id="katalog">
	<a href="/calendar/workshops/">Workshop</a>
	…
</turbo-frame>

A link inside the frame replaces only the frame, with the frame of the same name from the response.

Datastar (opens in a new tab) is often reduced to server-sent events. That falls short: according to its documentation, its server actions also accept plain HTML as a response. You only need SSE when the server should send several updates in a row. I still cannot demonstrate it meaningfully in my static demo, because its real core is a server that plays along.

fixi (opens in a new tab) is the most interesting experiment of the three: six attributes, no inheritance, no loading indicators, no history. Its author describes it as scheme to htmx's common lisp. For the tab it would look roughly like this:

<button fx-action="/calendar/workshops/" fx-target="#katalog" fx-swap="outerHTML">Workshop</button>

If you want to understand what htmx does at its core, you can read fixi's 90 lines in a quarter of an hour.

Pitfalls while building

The CSP and eval

Some htmx features generate JavaScript from attribute text at runtime, via new Function: the hx-on handlers, event filters in hx-trigger such as keyup[key=='Enter'], and values with the js: prefix in hx-vals and hx-headers. A Content Security Policy without 'unsafe-eval' forbids exactly that, and this site has none.

So I set allowEval: false:

<meta name="htmx-config" content='{"allowEval":false}'>

With that, htmx does not even run an hx-on handler and reports htmx:evalDisallowedError. That is what I measured live. Without the setting the result is the same, just louder: the CSP blocks new Function with an EvalError in the console.

The nastiest point is in the source code. For an event filter, htmx with allowEval: false falls back to a filter that always returns true. An hx-trigger="keyup[key=='Enter']" then fires on every key, not only on Enter. The console only shows a generic htmx:evalDisallowedError once at initialisation, which does not tell you that the filter is now being ignored. If you run htmx under a strict CSP, avoid event filters and resolve the condition on the server or in a script of your own.

One warning about measurement method, because it put me on the wrong track myself. My first test ran through the test browser's script evaluation (Playwright), and there the hx-on handler ran despite the CSP. Code triggered by the developer tools is exempt from the eval block. Only when the page itself triggered the code, through a real click, did the EvalError appear. If you test CSP behaviour automatically, do it from the page context.

The loading indicators and style-src

On load, htmx inserts its own <style> element for its loading indicators into <head>. This site allows 'unsafe-inline' in style-src, so it goes unnoticed here. Under a stricter policy the browser blocks the element. For that case htmx offers two ways out: set inlineStyleNonce, or turn off includeIndicatorStyles and put the rules for .htmx-indicator into your own stylesheet.

URLs no plugin rewrites

This site lives in the /db/ subfolder. A plugin in Eleventy automatically puts that prefix in front of every href and src. A look at its attribute list shows, however, that hx-get and hx-push-url are not on it. Without countermeasures htmx would have loaded /en/demo/… instead of /db/en/demo/…. So the htmx attributes get the prefix explicitly in the template. The pattern is general: every tool that rewrites URLs only knows the attributes it knows, and hx-* is rarely among them.

Events without a target

The first version of my five-line dialog script had no ?.. I did not notice at first during the click test, because I had not checked the console of the robust htmx page separately. It only showed up in the second pass: after a click on “back”, htmx restores the page from its history storage and fires htmx:afterSwap without detail.target. My script accessed target.id and threw an error. The fix was two question marks; the lesson is bigger: never assume all fields of an htmx event are filled.

By the way, since version 2.0.5 htmx 2 keeps these snapshots in sessionStorage; before that they lived in localStorage.

A guide to choosing

What follows from a demo with twelve events? No truth about large applications, but a clear tendency. This is my assessment:

htmx fits when your application is rendered on the server and should become livelier in a few places: filters, loading more, forms with feedback. The library is small, has no dependencies and can be introduced step by step. Its strength is that it prescribes little. That is also its weakness: focus, screen reader announcements and page titles are up to you. Part 3 shows how much that is in practice.

Unpoly fits when you want the same principle but need more ready-made answers: overlays, focus handling, history. You pay with just under three and a half times the file size and a larger API.

No library fits more often than I would have thought before building it. Real page changes with view transitions and native dialogs cover a large part of what I would once have reached for Ajax to do. Only the partial swap is missing.

A client framework fits where a lot of state lives in the browser: spreadsheets, maps, games, offline use. htmx is not made for that, and the project says so itself in the essay “When Should You Use Hypermedia?” (opens in a new tab).

htmx 2 or 4? For a new project I would look at 4, because that is where the library's future lies. For an existing project there is no hurry. The project itself keeps 2.x as the default into 2027.

What comes next

This part deliberately leaves one question open: what do search engines and AI systems actually see of content that htmx loads later? I measure that in Part 2 with check marks in every content block, via curl, in a headless browser and with an AI assistant. Part 3 goes through all four builds with the keyboard and measures where the focus goes after each swap. The overview of all parts is in the htmx series.