I want to be upfront: I like JavaScript. I use it professionally. But there’s a class of application — internal tools, dashboards, content sites — where the React/Next.js stack is genuinely overkill.
HTMX and Go is the answer I keep landing on for these. Minimal JS, fast server, hypermedia as the engine of application state.
The mental model shift
React/Vue are client-state managers with a render layer. HTMX is a HTTP request decorator for HTML elements.
Instead of fetching JSON and rendering it client-side, HTMX fetches HTML fragments and swaps them into the DOM. The server is the source of truth. Always.
<!-- React world: fetch JSON, manage state, re-render -->
<button onClick={() => fetch('/api/like').then(r => r.json()).then(setCount)}>
Like ({count})
</button>
<!-- HTMX world: just describe the intent -->
<button hx-post="/like/42"
hx-target="#like-count"
hx-swap="outerHTML">
Like
</button>
<span id="like-count">41 likes</span>
The server returns the new <span>. Done.
Go templates + HTMX = remarkably fast
Go’s html/template renders fast enough that even under load, full-page renders are sub-millisecond. HTMX fragments are even faster since they’re smaller.
A typical handler that returns a fragment:
// GET /posts?tag=go — returns either full page or fragment
func (h *PostHandler) List(w http.ResponseWriter, r *http.Request) error {
tag := r.URL.Query().Get("tag")
posts, err := h.svc.ListByTag(r.Context(), tag)
if err != nil {
return err
}
data := PostListData{Posts: posts, SelectedTag: tag}
// HTMX sends this header on partial requests
if r.Header.Get("HX-Request") == "true" {
return h.tmpl.ExecuteTemplate(w, "post-list-fragment", data)
}
return h.tmpl.ExecuteTemplate(w, "post-list-page", data)
}
The template:
{{define "post-list-fragment"}}
<div id="post-list" hx-swap-oob="true">
{{range .Posts}}
<article class="post-card">
<h2>{{.Title}}</h2>
<p>{{.Excerpt}}</p>
</article>
{{end}}
</div>
{{end}}
Real-time updates with SSE
For live dashboards, Server-Sent Events work beautifully with HTMX’s hx-ext="sse":
<div hx-ext="sse"
sse-connect="/events/metrics"
sse-swap="message">
<div id="metrics">Loading...</div>
</div>
The Go SSE handler:
func (h *MetricsHandler) Stream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return
case <-ticker.C:
metrics := h.collector.Snapshot()
var buf bytes.Buffer
h.tmpl.ExecuteTemplate(&buf, "metrics-fragment", metrics)
fmt.Fprintf(w, "data: %s\n\n", buf.String())
flusher.Flush()
}
}
}
Optimistic UI with HTMX indicators
HTMX has built-in request indicators — no spinner management in JS:
<button hx-post="/publish/42"
hx-indicator="#publish-spinner"
hx-disabled-elt="this">
Publish
<span id="publish-spinner"
class="htmx-indicator">
<!-- SVG spinner here -->
</span>
</button>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
.htmx-request.htmx-indicator { display: inline; }
The spinner appears automatically while the request is in flight. No state, no useState, no component re-renders.
Where HTMX genuinely struggles
I won’t oversell it. HTMX has real limitations:
- Complex client state: a multi-step wizard with branching logic is painful without client state management
- Optimistic updates: hard to roll back server state on failure cleanly
- Offline support: server-dependent by design, no service worker story
- Collaborative real-time: Google Docs-style conflict resolution is out of scope
For these cases, use React or a thin JS layer on top of HTMX. The hybrid works fine.
My rule of thumb
If the feature can be described as “user does X, server responds with Y”, HTMX handles it. If it requires “client tracks state Z across multiple server interactions simultaneously”, reach for JS.
For most internal tools, the former covers 90% of features.