htmx instead of a single-page app
Part of my Krabber series, a Twitter clone in Go. The full source is on GitHub.
Intro
Krabber feels like a modern social app. You like a molt and the button updates, you follow a krab and it flips to Following, you scroll and more loads, and new molts announce themselves at the top. And there’s no React, no client-side router, and no front-end build step anywhere. It’s server-rendered Go with htmx doing the interactive parts, and I want to show how little it takes, because people assume this kind of interactivity requires a framework.
I. The whole idea
htmx lets an ordinary HTML element make an HTTP request and swap the response into the page. The server’s job doesn’t change: it renders HTML, same as always, except sometimes it renders just a fragment instead of a whole page. There’s no JSON API feeding a separate front-end, because the thing the server returns is the thing that goes on the screen.
So a follow button is just this:
<button hx-post="/follow/{{ $c.ID }}" hx-swap="outerHTML">Follow</button>
Click it, htmx POSTs to /follow/<id>, the server renders the button in its new state (now an Unfollow button), and htmx swaps the old button for the new one. No JavaScript I wrote, no state to sync, no re-render of anything else on the page. The server already knows how to render that button, so it just renders it again.
II. The patterns that cover a social app
A handful of htmx attributes cover nearly everything Krabber does:
- In-place updates use
hx-swap="outerHTML"to replace an element with its new version, like the like and follow buttons. - Infinite scroll loads the next page of molts and appends it with
hx-swap="beforeend", so the feed grows instead of navigating. - Modals load their contents on demand into a target like
#molt-modal-content, so the markup isn’t sitting in the page until you need it. - Live polling is a nice one: a
hx-trigger="kb:poll from:body"fires on a timer and checks for new molts, which is how the “new molts” indicator appears without a websocket.
Every one of those is the same shape: an element makes a request, the server renders a fragment, htmx drops it in the right place.
III. Why I keep choosing this
A few things make this more than a novelty for a site this size.
It’s one language. The whole app is Go and templates. There’s no second codebase in TypeScript, and no second copy of the business rules living in a front-end that can drift from the server’s.
It works before JavaScript does. Pages are real server-rendered HTML, so they show up and the links work even before htmx loads. The interactivity is an enhancement on top of a page that already functions, not a blank div waiting for a bundle.
And it’s safer by default. I configure htmx to refuse the dangerous things: no eval, no running script tags from responses, and requests only to my own origin. That let me keep the strict Content-Security-Policy I wrote about in the browser-hardening post, because there are no inline scripts for it to forbid.
Conclusion
You can build a genuinely interactive social site, likes and follows and infinite scroll and live updates, without a single-page framework or a build step, by letting the server keep doing what servers are good at and swapping in the HTML it renders. For Krabber that’s less code, one language, pages that work before JS, and a tighter security policy as a bonus. If you’re reaching for React out of reflex on something small, try rendering a fragment and swapping it instead. Thanks for reading, and may your pages stay simple.