We Chose HTMX Over React for Our App

By Mark Dumay
Mark spent 20 years as a data and analytics consultant, first at a large international firm, then at a boutique practice, working with telcos, utilities, and wholesale businesses. At some point he got tired of recommending the same fixes and decided to build them instead.

The slug rule for new organizations lives in three places: our API, an HTML attribute, and a JavaScript helper. For a while the copy in the attribute was invalid in current Chrome, which silently ignores a pattern it cannot compile. Fixing it changed nothing a user could see, because the form turns off browser validation. Nothing told us. The server’s answer is the one that counts, and the duplicated rule sits in the markup doing nothing. That quiet bug is the clearest explanation I have for why our app uses HTMX instead of React.

When we started on the Amfelt app, the front-end question was open, and my own background decided what I could judge quickly. I had a little Angular behind me and a lot of .NET from earlier in my career, and the tools I knew best were Go and Hugo. We looked at React with Next.js. I did not want another toolchain to learn, and React seemed to bring a good deal of client-side state management and overhead with it, on top of the server we had to build anyway.

The question that decided it was narrower than React or not. We had already put effort into Hugo, which builds static pages, and the app needed a mix of static and dynamic content. Before committing to a direction, we built a technical proof of concept to find out whether that static work could carry over into the app or a React front end would replace it.


The Usual Answer  

For a data-heavy business app, the usual answer is a single-page application (SPA): the browser owns the interface and its state, and the server exposes JSON. It is a sensible default. Hiring is easier and the component ecosystem is huge. It also has a price that is easy to miss when you already have a REST API: whatever the server knows about a record, the browser has to know again, and the two copies have to stay in agreement.

Take the URL slug a user picks for their organization. The format rule is easy to check in a browser: lowercase letters, digits and hyphens, at most 63 characters. The rules that trip people up are different: the slug is already taken, or it is reserved. Only the server can answer those, because they need the database. A browser-side check can therefore only ever cover the easy half, and the server has to run every check regardless. Adding the browser copy means writing the same rule twice and keeping the copies in agreement, and as our three-place slug rule showed, copies drift without anyone noticing.


What We Do Instead  

Hugo renders the chrome: the layout, the navigation, the buttons, the icons and the placeholders where dynamic content goes. The Go server returns HTML fragments, and HTMX swaps them into place. Each dynamic component is a Hugo shortcode that emits the outer shell and the HTMX attributes, plus a Go template that renders the fragment inside it. The fragment refers to icons that Hugo registered at build time, so there is only one icon pipeline. The Same Pairing of Hugo and a Small Go Server is what gates our knowledge base.

Validation stays with the server. Here is the create-organization form, abridged. It posts to the server, replaces itself with the response, and has no client-side validation logic:

<form id="create-org-form"
      hx-post="/web/onboarding/create-org"
      hx-target="this"
      hx-swap="outerHTML"
      novalidate>
  <input name="slug"
         class="form-control{{ if .SlugError }} is-invalid{{ end }}">
  {{- if .SlugError }}
  <div class="invalid-feedback">{{ .SlugError }}</div>
  {{- end }}
</form>

The handler forwards the request to our API. When the API rejects it, the handler maps the error to the field it names and renders the same template again:

err := client.PostJSON(c, "/v1/orgs", createOrgRequest{Slug: slug, DisplayName: display}, &resp)
if err != nil {
    data := mapCreateOrgError(c, err)
    data.Slug = slug
    data.DisplayName = display
    Render(c, "onboarding-create-org-form.go.tmpl", data)
    return
}

Only the server decides, and there is no client-side state to reconcile with it. We still keep a format hint in the markup, but it is a convenience; the server’s answer is the one that counts. The same reasoning has ruled things out. When we looked at a richer surface for configuring workflows, the React options, a canvas node editor for example, would have put a React island inside an otherwise HTMX app. We wrote that down and decided to extend the table library we already use with server-side bulk editing instead.


What It Costs Us  

HTMX moves the work from the client to the server. It is a real shift, and generating HTML on the server still feels awkward to me, especially when a fully fledged REST API already produces JSON. Our web handlers call that API and then turn the result into markup, so one operation has two representations. We keep the reusable pieces manageable with templates and dynamic components that build on the static library, but the duplication is real: the create-organization form exists once in Hugo and once as a Go template, and the template’s own header comment says it must mirror the Hugo version.

There are smaller costs. Editing in our configuration grid stays row-based instead of click-into-cell. We run htmx 4, which dropped its history cache, so the browser’s Back button now re-fetches the page from the server. We accepted that instead of storing rendered, authenticated pages in the browser. And HTMX is not usually at the top of a candidate’s CV, although in our experience it does not have a steep learning curve.

The costs would be higher for a different kind of app. Ours is mostly a CRUD shell with a limited number of dynamic controls. The htmx authors are candid about the limits of the approach  : they list interfaces with many dynamic interdependencies, offline use and very frequent state updates as a poor fit, and they name Google Sheets as an example. If your interface resembles a spreadsheet more than a set of forms and tables, weigh that before you follow us.

What I keep coming back to is that a front-end choice is a choice about how many copies of the truth you are willing to maintain. For an app like ours, the honest number of copies that count is one. The same preference for the simplest structure that fits is why Our Back End Is a Modular Monolith .

Published
Nov 1, 2026
Category
Engineering