Cookies on Tamperan

We use cookies and similar technologies for the things below. You can accept all, reject everything except what's essential, or pick what you're OK with.

Preferences
Remembers things like your last workspace and how you had a list sorted. Improves the experience but the site works without.
Improvement
Anonymous usage measurement so we can fix bugs and prioritise work.
Marketing
Lets us measure whether ads we run send people who actually use the site. We don't share personal data with advertisers.

Read our cookies policy and the privacy policy.

Loading…

Notes

htmx at scale, with the whole screen shown

The usual concession is that htmx is fine for small things and you'll want a real framework eventually. 709 templates and a dozen apps later I think that has it backwards, and here's a complete filtered, paginated, inline-action screen so you can judge rather than take my word.

Shane Wright maintained Last updated htmx go frontend architecture

The concession usually arrives politely. htmx is great for CRUD, lovely for a small app, but for anything large you'll want a proper front-end framework.

I have twelve applications in production built this way. Some measurements before the argument, because the argument is worthless without them:

  • 709 .gohtml templates across the estate
  • 187 of them use htmx
  • zero React, Vue, Svelte or Next - grep returns nothing
  • a typical app's built JavaScript bundle: 6,197 bytes

Three apps are heavier - 713KB, 752KB and 775KB. Those are the ones with a rich text or site editor in them, and I will come back to why, because those three exceptions are where this argument actually stops.

The screen

Here's a complete admin screen: two filters, a paginated table, and per-row actions that update the list in place. No custom JavaScript at all. This is the whole thing, not an excerpt.

The page:

<div id="filters" class="flex gap-2 mb-4">
    <select name="kind"
        hx-get="/manage/submissions:list"
        hx-include="#filters"
        hx-trigger="change"
        hx-target="#list">
        <option value="">All kinds</option>
        {{selectoptions (.Data "kindOptions") (.Data "kind") ""}}
    </select>
    <select name="status"
        hx-get="/manage/submissions:list"
        hx-include="#filters"
        hx-trigger="change"
        hx-target="#list">
        <option value="">All statuses</option>
        {{selectoptions (.Data "statusOptions") (.Data "status") ""}}
    </select>
</div>

<div id="list">
    {{template "submissions:list" .}}
</div>

The list, which is its own named block:

{{define "submissions:list"}}
    <div class="text-xs">{{.Data "pr"}}.Total submission(s)</div>
    <table class="table table-sm">
      <tbody>
        {{range $s := .Data "submissions"}}
          <tr>
            <td>{{tam_date $rc $s.CreatedAt}}</td>
            <td>{{$s.GameName}}</td>
            <td>{{template "status-badge" $s.Status}}</td>
            <td class="text-right">
              {{range $opt := $statusOptions}}
                <button hx-post="/manage/submissions/{{$s.ID}}:status"
                        hx-vals='{"new_status":"{{$opt.Value}}"}'
                        hx-include="#filters"
                        hx-target="#list">{{$opt.Label}}</button>
              {{end}}
            </td>
          </tr>
        {{else}}
          <tr><td colspan="7">No submissions match these filters.</td></tr>
        {{end}}
      </tbody>
    </table>

    {{pagination $rc (.Data "pr") "#list" (.Data "listURL")}}
{{end}}

And the Go. Both routes - the full page and the fragment - share one loader:

func routeList(w http.ResponseWriter, r *http.Request) {
    rc := web.RequestContext(w, r)
    if !loadSubmissions(w, rc, r) {
        return
    }
    must.OrWarn(tmpl.ExecuteTemplate(w, "submissions:list", rc))
}

func loadSubmissions(w http.ResponseWriter, rc web.RequestContextInterface, r *http.Request) bool {
    // FormValue merges query + body, so filters survive whether they arrive on
    // a GET list request (query) or an hx-include'd status POST (body).
    kind := r.FormValue("kind")
    status := r.FormValue("status")
    offset := atoiDefault(r.FormValue("offset"), 0)

    subs, pr, err := core.GetSubmissions(rc.Context(),
        core.FilterSubmissions{Kind: core.SubmissionKind(kind), Status: core.SubmissionStatus(status)},
        dbx.FilterPagination{Limit: listLimit, Offset: offset})
    if handleErr(w, rc, err) {
        return false
    }

    rc.SetData("submissions", subs)
    rc.SetData("pr", pr)
    rc.SetData("statusOptions", core.SubmissionStatusOptions())
    rc.SetData("listURL", "/manage/submissions:list?"+filtersOnly(kind, status))
    return true
}

That's the entire feature. Filter, paginate, mutate, re-render - and the fragment handler is six lines because the page handler and the fragment handler load the same data and render the same named block. There's no list endpoint returning JSON, no client-side store holding a copy of the rows, no loading state to manage, and no second definition of what a submission looks like.

Two details in there are worth stealing.

hx-include="#filters" on the action buttons. A status change POST carries the current filters with it, so the re-rendered list comes back filtered exactly as it was. Without it, changing a row's status silently resets the view, which is the sort of thing that gets reported as "the page jumps".

FormValue merges query and body. The filters arrive as a query string on the GET and as form fields on the POST, and one loader reads both without caring. That comment is in our source because it took someone twenty minutes to work out the first time.

The argument, which is about copies

What kills a large front end isn't interactivity. It's the second copy of your domain model.

A single-page app re-describes your types in TypeScript, your validation in a form library, your routing in a router, and your permissions in the client. Four places that can disagree with the server, and they will, and the disagreements surface as bugs that reproduce on one machine.

The screen above has one description of a submission. It lives in Go. The template renders it. There's nothing to keep in step.

That property gets more valuable as the surface grows, not less, which is why I think the usual concession is backwards. At one screen, a SPA's duplication is cheap. At several hundred screens across a dozen apps, it's the dominant cost.

Sharing components without npm

The scaling question people actually mean is: how do you share UI across applications?

A component here's a template plus its handler, shipped from a shared library and picked up by a build command. No package registry, no version skew between what the browser has and what the server thinks it has, no publish step. A library gains a component, the consuming apps pick it up, and because the server renders it there's exactly one version live at any moment.

The thing a JavaScript build gives you - tree shaking, code splitting - solves a problem we mostly don't have, because 6,197 bytes doesn't need splitting.

With a team, and with people who have never seen it

The other scaling question is people, and it's the one put to me with the most scepticism. I have run this shape on larger multi-app product suites than this one - thousands of templates, teams north of twenty - so what follows is from there rather than from here.

Hiring is easier than expected, partly because I am not hiring for it. I do not tend to hire front-end specialists, and that shapes everything below. Candidates have either never heard of htmx or are mildly curious about it. Nobody has arrived already knowing it and nobody has needed to; it goes in within a day or two. What people say afterwards is consistently about the simplicity and the speed, and nobody has been unhappy working with it yet.

The part that actually surprises people is the build. There's no JavaScript build pipeline in CI. There's a build - esbuild, the Tailwind CLI and DaisyUI - and it's one command:

tam assets --build

It runs on a developer's machine, and its outputs, ui/assets/css/main.css and ui/assets/main.js, are committed to the repository. CI never installs a JavaScript package; the build spec has no node step in it at all.

That's deliberate, and the reason is supply chain rather than taste. A pipeline that installs packages on every commit is a pipeline that can be persuaded to install something else, and the blast radius of that's every artefact you ship. It's also a smaller sacrifice than it sounds, because what's being built is mostly CSS. The JavaScript it emits is the 6,197 bytes above.

The cost is real and it's the one people trip on: the outputs sit in the diff, so a change to a template's classes and the rebuild have to land in the same commit. Forget, and you ship last week's CSS with this week's markup. That needs a check at commit time - one that runs the build and refuses a commit whose outputs don't match - because it doesn't survive being remembered.

Worth separating from the section above: the npm here's toolchain only. It builds a stylesheet. It's not how components are distributed, and nothing in a package registry describes a screen.

Interactivity on top is vanilla JavaScript, and it's good enough for essentially all of it. Where a job genuinely needs a real library we use one, and the list is short and deliberate - Lexical for rich text is the clearest case, and it's one of the three heavy bundles above. The rule was never "no JavaScript". It's that a dependency has to earn its place, and on this kind of surface most of them cannot.

What I cannot tell you is what happens when front end and back end are separate departments, with separate managers and separate roadmaps. That's the real objection from anyone at organisational scale, and I haven't run it that way - the teams I have run this with weren't split down that line, which is arguably part of why the no-second-copy argument held for them. If your organisation is shaped that way, treat all of the above as evidence from a different shape rather than an answer.

Where it genuinely hurts

This is the section that decides whether the rest was worth reading.

Rich editors. A site builder or a document editor wants real client-side state, and htmx layered over that's worse than either approach on its own. That's what the 713KB, 752KB and 775KB bundles are. htmx was the wrong tool for those three screens and we used the right one.

Optimistic UI. You can approximate it. It's not free and it's not as good.

Anything offline. Not a candidate.

Debugging a failed swap, which is the strongest objection and the one I would lead with if I were arguing the other side. When a swap goes wrong you get a silent 200 with the wrong fragment in it. No stack trace, no red in the console, and the page just looks subtly incorrect. A JavaScript framework would have thrown.

The only answer we have found is discipline: drive the actual screen in a real browser with the console open before calling it done. That's a weaker guarantee than a type error, and it's a real cost that belongs in the trade rather than being waved away.

What "large" means here

Large in surface area - many screens, many domains, many applications - and not large in concurrent client-side state.

If your product is a spreadsheet, a design tool, a diagram editor or anything where the interesting state lives in the browser and the server is a persistence layer, none of this transfers and you should stop reading. The distinction is not size, it's where the state lives, and htmx is a good answer only when the answer is "the server".


Got a view on this? Corrections, disagreements and war stories are all welcome. This one is maintained, so if it has gone out of date I will fix it. Get in touch.

More notes · Writing