
My Experience Building a Social Website for My Startup with Go + HTMX 4 (Long Post)
Conclusion first: HTMX is VERY production ready for a large-scaled project. And thankfully I ditched Vue.js 2/3 and Svelte 4 for HTMX. (Never used React though.)
I literally cried when HTMX mentioned they’re trying to be the next jQuery, that might not need to change for the next decade.
In the AI era, adding features becomes easy, but people just don’t know how to make things simple and “just work.” I really hope HTMX stays this way. (please u/_htmx 😭🙏)
The startup is basically an old Twitter clone, from when social media felt friendlier and the web didn’t feel like a bunch of boring mobile apps. Sorry for the mixed-language screenshots, I hadn’t finished internationalization yet.
Here are a few of my experiences with HTMX:
1. Make the website stupid and just make it work
We don’t use a cool dialog library like SweetAlert.js. The native alert(), confirm(), and prompt() just work. With hx-confirm, it’s even simpler.
If you need a form, just use <form>, and refresh the whole page if you can (or hx-post if you don’t want to), so you don’t have to worry about partial updates or syncing what changed.
2. We don’t use Alpine.js
It’s a very good solution if you want some hybrid JS, but it reintroduced the state management problem.
We had server-rendered JSON inside <script type="application/json"> so Alpine.js could load the data back. It worked, but caused some flickering since Alpine runs after the page is rendered, and introduced another state we had to manage.
so we decided to keep everything server-side rendered. (yea, fuck the network latency 🥳🎉)
We took this pretty far. When you upload photos in a post creation form, normally you’d just spawn the previews with JavaScript. Instead, we turn the images into blob URLs, pass those URLs to the server, and the server returns something like:
<div class="photo-preview">
<img src="{{ .ImageURL }}">
</div>
So when the user edits the post later, we can render the exact same “photo preview” component on the server. One component, one rendering path.
>ℹ️ But there are also things you can just ditch.
>.
>We had a post poll where you could use +/- buttons to add or remove option inputs with Alpine.js. Turns out we only support up to 4 options anyway, so we ditched the +/- JS and just put 4 fixed text inputs in the poll form.
>.
>Now we can render the option text directly into the inputs, instead of outputting it as JSON in a <script> for Alpine.js to load back into them when user is editing the poll.
It sounds stupid. That’s kind of the point. You sacrifice some UX (user experience), but get much better DX (developer experience).
3. We don’t use Templ or framework with HTMX with Go
Since Templ requires build-tools, learning special syntax, IDE extensions, we decided to just use Go’s html/template and split the HTML into layouts, pages, and partials. The definitions look roughly like this:
type AppLayout struct {
Navbar *Navbar
}
type ProfilePage struct {
*AppLayout
User *User
FollowButton *FollowButton
}
type FollowButton struct {
IsFollowing bool
}
Then compose the data in the page handler:
user, err := db.GetUser(c.Param("username"))
if err != nil {
return c.Abort(http.StatusInternalServerError)
}
layout := &AppLayout{
Navbar: NewNavbar(),
}
// Tada- now you have a full page! 🎉
page := &ProfilePage{
AppLayout: layout,
User: NewUser(user),
FollowButton: NewFollowButton(user.IsFollowing),
}
And if you want to render the Follow Button component:
{{ template "follow_button" .FollowButton }}
Since AI can write the boilerplate now, code can be stupid and boring, but easy to debug. And with HTMX, you can just return any partial when needed.
There’s no "runtime magic" to trace if error occurred, back when Vue.js + hot reload pointing stack traces to some generated chunks.js instead of the actual code.