ankush.fyi — writings
31 jan 2026 · 8 min read htmx go web development sse hypermedia

HTMX + Go: The Stack I Keep Coming Back To

Everyone's chasing the next JS framework. I rebuilt three internal tools with HTMX and Go and shipped faster than any React project I've done. Here's the architecture.

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.

← all writings