HTMX: Most Sites Don't Need a Framework
HTMX is a lightweight JavaScript library that allows developers to create modern, dynamic web applications by extending HTML with custom attributes instead of writing complex frontend JavaScript code. Here is the case for HTMX, an honest comparison with React, and the places where the hype runs ahead of the engineering.
If you filtered this blog by category or typed into the search box on the way here, you used the only interactive machinery on this site. The list of posts updated in place, the URL changed to match, and the browser back button took you where you expected. The contact form behaves the same way: you hit send, a spinner appears, and the form is replaced by a confirmation without the page reloading.
There is no React behind any of that. No Vite, no bundle, no hydration, no client-side router, no node_modules directory. It is a handful of HTML attributes and a few Go handlers that return fragments of HTML. The library that makes it work is HTMX, and it is about 14 kilobytes minified and gzipped, with zero dependencies.
I have shipped a lot of React applications, built backends and APIs in Node.JS, created Firebase-backed products, and enough component trees to have real opinions about them. So this is not a "JavaScript bad" post. It is a post about the fact that I keep noticing how much machinery I was carrying for problems I did not actually have, and how many of the sites I look at every week are in exactly the same position.
What HTMX actually is
HTMX simplifies modern web development by allowing developers to access AJAX, CSS Transitions, WebSockets, and Server Sent Events directly from HTML attributes. Instead of building heavy single page applications with complex JavaScript frameworks, HTMX lets you return HTML fragments straight from the server to dynamically update parts of a webpage. This approach drastically reduces client side complexity, speeds up initial page load times, and allows backend developers to build interactive user interfaces using the languages and tools they already know best.
The clearest way to understand HTMX is the way its own documentation frames it. Start with an anchor tag:
<a href="/blog">Blog</a>
That tag tells the browser: when the user clicks, issue an HTTP GET to /blog and load the response into the window. It is a declarative network request expressed as markup. HTML has had this since the beginning.
The observation behind HTMX is that HTML stopped there, somewhat arbitrarily. Only anchors and forms can issue requests. Only clicks and submits can trigger them. Only GET and POST are available. And the target is always the entire window.
HTMX removes all four of those restrictions:
<button hx-post="/clicked"
hx-trigger="click"
hx-target="#parent-div"
hx-swap="outerHTML">
Click Me!
</button>
Any element can issue a request. Any event can trigger it. Any HTTP verb works. Any element on the page can be the target, and you control how the response is swapped in. That is essentially the whole library. The rest of the attribute surface, hx-indicator, hx-confirm, hx-include, hx-sync, is refinement on that core idea.
The consequence is the part that matters architecturally: your server returns HTML, not JSON. There is no serialization layer, no client-side model that has to be kept in sync with the server model, and no second rendering implementation on the client. The server renders the truth, and the browser puts it where you told it to go.
Carson Gross has been working toward this for a long time. HTMX grew out of intercooler.js, which he started back in 2013, and the intellectual lineage runs straight to Roy Fielding's original description of REST, specifically the part about hypermedia as the engine of application state that the industry quietly dropped when "REST API" came to mean "JSON over HTTP."
Filtering this blog, concretely
The blog index has a search box and a row of category pills with post counts next to them. Click Engineering, the list narrows. Type into the search box, the list narrows as you type. The URL updates to match, so you can share the filtered view or hit back and land where you were.
In a React application this is the canonical starter feature. You would hold a query string and an active category in state, debounce the input, fire a request or filter a local array, handle the loading and empty states, sync everything into the URL with your router, and handle the case where two requests come back out of order. None of it is difficult. All of it is code you own forever.
Here is the HTMX version, trimmed of styling:
<input type="search" name="q"
placeholder="Search"
hx-get="/blog"
hx-trigger="keyup changed delay:300ms, search"
hx-target="#post-list"
hx-swap="outerHTML"
hx-include="[name='category']"
hx-push-url="true">
<a href="/blog?category=Engineering"
hx-get="/blog?category=Engineering"
hx-target="#post-list"
hx-swap="outerHTML"
hx-push-url="true">Engineering · 6</a>
<div id="post-list">
<!-- rendered by the server -->
</div>
And the handler, in the Go layer sitting on top of PocketBase:
func handleBlogIndex(e *core.RequestEvent) error {
q := e.Request.URL.Query().Get("q")
category := e.Request.URL.Query().Get("category")
posts, counts, err := findPosts(q, category)
if err != nil {
return err
}
// an HTMX request wants just the list, a normal one wants the page
if e.Request.Header.Get("HX-Request") == "true" {
return render(e, "partials/post_list.html", posts, counts)
}
return render(e, "pages/blog.html", posts, counts)
}
That is the entire feature. Four things about it are the actual argument, not the syntax.
The filter logic exists once, and it is a database query. There is no client-side array being filtered in parallel with a server-side query that has to agree with it. The category counts next to each pill come out of the same query that produced the posts, so they cannot drift out of sync with the list, which is exactly the class of bug that eats an afternoon in a client-side implementation.
Deep links and history come from the platform. hx-push-url puts the filtered URL in the address bar and the state in the browser's history stack. I did not install a router. The one rule to respect is that any URL you push must return a full page when requested directly, because someone will paste it into an email. That is what the HX-Request check in the handler is for: the same endpoint serves a page to a browser and a fragment to HTMX.
It degrades. Notice the category pill is still an anchor with a real href. With JavaScript off, or if HTMX fails to load, it is a link to a page that works. The search box can be wrapped in a form with an action for the same reason, trading live filtering for a submit. This is progressive enhancement working the way it was supposed to, and it costs close to nothing when the enhanced and unenhanced paths share a server-side implementation.
Race conditions are handled by the library, not by me. The delay:300ms modifier on hx-trigger debounces the input, and the countdown resets on each keystroke. If you want stricter guarantees when several elements can fire overlapping requests, hx-sync lets you declare that one should abort or queue behind another. In React I would be writing that coordination myself, or pulling in a data-fetching library that writes it for me.
The contact form on this site is the same pattern with a different verb. hx-post to an endpoint, target the form itself, swap outerHTML, and the server returns either the form with validation errors filled in or a success message. What I want to highlight there is that validation lives in exactly one place. In a React version I would validate on the client for responsiveness and again on the server because client validation is always bypassable. Two implementations of one behavior, drifting apart forever. Here there is the browser's built-in constraint validation, which HTMX respects before it will even issue the request, and then the server. I wrote rules in one place.
The evidence that it scales past toy examples
The obvious objection is that a personal blog is a low bar. Fair. The most cited counterexample is Contexte, a French SaaS company that replaced a React single page application with Django templates and HTMX and published the numbers:
- Codebase from about 21,500 lines to 7,200, a 67% reduction
- JavaScript dependencies from 255 down to 9
- Build time from 40 seconds to 5
- Time to interactive on first load cut by roughly half
- Roughly two months of work, replacing a UI that took two years to build
- No reduction in the user experience
There is a second-order effect in that report that I find more interesting than the line counts. Before the migration, the team had a hard split: two backend developers, one frontend developer, one full stack. After, that division mostly dissolved, because there was no longer a separate frontend discipline with its own toolchain and its own idioms. That is an organizational cost that never shows up in a bundle size comparison, and in my experience it is the more expensive one.
I want to be careful here, and so was Carson Gross, to his credit. The HTMX site's own essay on when to use hypermedia points out that Contexte is an unusually good fit: a media application that displays and categorizes articles, with a sophisticated filtering mechanism on top. That is close to the platonic case for hypermedia, and it is also, at a much smaller scale, exactly what my blog index is. You should not read 67% as a number you will reproduce on a different shape of application.
David Guillot, who led that migration, wrote a follow-up that I think is the healthiest framing anyone has put on this debate. He does not hate JavaScript. He uses it whenever he needs pure client-side interaction. He did not do the migration to avoid writing JavaScript. He did it to improve the user experience while reducing cost. That is engineering, not ideology.
HTMX and React, compared fairly
The disagreement between HTMX and React is not really about syntax or bundle size. It is about where application state lives.
React treats the browser as an application runtime. State lives on the client, the server is a JSON API, and the UI is a function of that client state. HTMX treats the browser as what it has always been, a hypermedia client. State lives on the server, and the UI is HTML the server sends you.
Everything else follows from that one decision.
| HTMX | React | |
|---|---|---|
| State lives | On the server | On the client |
| Server returns | HTML fragments | JSON |
| Build step | None required | Effectively mandatory |
| Rendering logic | Written once, server-side | Written client-side, plus SSR if you want it |
| Cost per interaction | A network round trip | Usually zero after load |
| Offline | Not possible | Achievable |
| Ecosystem | Small | Enormous |
Read that table honestly and neither column wins. They trade different things.
Where HTMX is the better engineering choice. Content-heavy sites. Blogs, marketing sites, documentation. CRUD applications where the core mechanic is showing a form and saving it. Admin panels and internal tools. Dashboards that read more than they write. Anything where the interaction density is low enough that a round trip per interaction is imperceptible. This is not a narrow slice. This is the majority of what gets built, and it is where React's cost is paid without its benefit being collected.
Where React earns its complexity. Applications with genuine client-side state at fine granularity. Rich text editors, where a server round trip per keystroke is obviously absurd. Collaborative tools. Drag and drop builders. Anything spreadsheet-like, where an arbitrary formula can create arbitrary dependencies between cells that no server render can predict. Anything that must work offline. The HTMX docs name Google Sheets and Google Maps as the canonical examples, and they are right to.
The useful mental model is not either/or. Rich Harris called it a "transitional" application, mixing both, and the HTMX team agrees with him. The asymmetry worth knowing is that it is considerably easier to embed an isolated client-side component inside a hypermedia application than to do the reverse. If one screen in your admin panel needs a real client-side widget, drop one in and let it communicate with the rest of the page through DOM events. You do not have to convert the other forty screens to justify it.
Where I think the enthusiasm gets ahead of the engineering
I like HTMX. I still want to flag the places where the discourse around it is weaker than the library.
The benchmark numbers circulating are mostly junk. You will find blog posts claiming an HTMX dashboard loaded in 412ms against 2,847ms for its React equivalent. Those comparisons are almost never controlled. Different data volumes, different hosting, different caching, and frequently a React implementation that nobody optimized because the point of the post was to make React look slow. The Contexte numbers are credible because they measured the same product before and after with the same team. Treat anything that does not meet that bar as marketing.
Latency is a real constraint and "edge computing solves it" is a dodge. The claim that modern edge runtimes have erased the cost of server round trips is true for a static site served from a CDN and much less true for an authenticated request that has to reach your database. If your median round trip is 40ms, HTMX interactions feel instant. If it is 300ms because your users are in Australia and your Postgres instance is in Virginia, they will not, and no amount of hypermedia philosophy fixes that. Measure your own p95 before you decide.
Server load moves, it does not vanish. Rendering HTML costs more CPU than serializing JSON, and you are now doing it on every interaction rather than once. For most applications this is irrelevant compared to what you saved. For high-traffic applications with expensive templates it is a real line item that deserves a load test, not a shrug.
The component ecosystem gap is genuine. React has shadcn/ui, Radix, and twenty years of accumulated component libraries. HTMX has none of that, by design, and the docs say so plainly. If your velocity depends on assembling pre-built accessible components, you are trading that away, and you should price it.
Hiring is a real constraint. Every job posting says React. Many hiring managers have never heard of hypermedia as an architecture. The HTMX docs themselves recommend adopting it around the edges, starting with internal tools, rather than betting a company on it. That is unusually honest advice from a project's own marketing page, and it is correct.
Declarative code is harder to debug. This is the tradeoff nobody mentions. When something does not happen in imperative code, you set a breakpoint. When something does not happen in attribute-driven code, you have to figure out why an event did not fire, or why an attribute was inherited from three levels up. htmx.logAll() exists for exactly this reason. The team knows.
And there is a security surface. HTMX makes HTML more powerful, which means injected HTML is more powerful too. If you ever render unescaped third-party content, you now have to strip hx- attributes along with <script> tags. The library gives you hx-disable, a same-origin request restriction on by default, and the ability to turn off eval entirely, but the discipline is on you.
HTMX 4 is coming, and the way it is being handled is the real signal
Gross had publicly promised there would never be an HTMX 3. He is keeping that promise on a technicality by going straight to 4, because replacing the ancient XMLHttpRequest core with fetch() breaks enough behavior to require a major version.
The substance is a simplification pass. Attribute inheritance becomes explicit rather than implicit, which he describes as the biggest mistake in the earlier versions, inspired by CSS and about as maddening. History support stops snapshotting the DOM into local storage, which was a persistent source of bugs and a mild security concern, and just re-requests the content instead. Idiomorph-style morphing swaps and streaming responses, including Server-Sent Events, move into the core.
But the part I actually care about is the release management. HTMX 2 will be supported in perpetuity. The 4.0 line rolls out over multiple years, sitting as next while 2.x remains latest. As of now the project is deep in the beta and release candidate stretch, with 2.0.x still the version you should be shipping. That is a maintainer treating other people's production systems as a real constraint rather than an inconvenience. After the last decade of frontend churn, that alone is worth something.
The bottom line
The question I ask before reaching for a framework is not "which one." It is: does this application have client-side state that the server cannot know?
If the answer is no, and for a blog, a marketing site, an admin panel, a documentation site, or most CRUD applications the answer is no, then a client-side framework is asking you to pay for a runtime, a build pipeline, a dependency tree, a second rendering implementation, and a state synchronization problem in exchange for solving a problem you do not have.
If the answer is yes, use React. I will keep using it too, without embarrassment, because there is a class of application where its complexity is not accidental. It is the actual shape of the problem.
What I think is happening in 2026 is that the default finally moved. For a decade the burden of proof was on you to justify not starting with a framework. I think that has flipped, or should. Start with HTML, add HTMX where you want the page to stop reloading, and reach for a client-side framework at the point where you can name the specific interaction that requires one.
A filterable list of posts and a form that does not reload the page did not require one. Neither, I suspect, does most of the web.