Go + HTMX Full-Stack: Simpler Than React/Svelte, Even LLMs Get It
A backend engineer abandons Svelte for Go + HTMX + templ + sqlc, eliminating frontend state sync bugs, enabling type-safe HTML partials, and improving LLM code reviewability, with practical patterns for search, lazy loading, OOB swaps, and auth redirects.
Introduction: A Failed Side Project Sparks a Rethink
In 2021, backend engineer Haseeb Majid attempted a multiplayer party game called Banter Bus. The backend used WebSockets for state and anti-cheat, which he handled easily. The frontend, built with Svelte, became unmanageable as stores, global state, and synchronization logic spiraled out of control. The project was abandoned.
In 2023–2024, after reading HTMX articles, he tried again and finished the project. At GopherCon UK 2026 he presented the stack using his own gardening app "Let's Go" as the case study.
What Is HTMX?
HTMX is a ~50 KB (gzipped) library that lets you issue requests directly from HTML attributes. The flow is four steps: (1) an element declares hx-post (or hx-get, etc.) with a URL, (2) the browser sends the request, (3) the server returns an HTML fragment, (4) HTMX swaps that fragment into the target element.
<form hx-post="/plants/1/events" hx-target="#timeline">
<input name="event_type" placeholder="浇水 / 换盤 / 施萶" />
<textarea name="notes"></textarea>
<button type="submit" 记录</button>
</form>
<div id="timeline"><!-- server HTML replaces here --></div> hx-post: submit a POST to the given endpoint. hx-target: where to put the returned HTML (defaults to the triggering element).
No JSON serialization, no client-side state management, no hand-written fetch.
Why It’s Worth Trying
1. State Lives Only on the Backend – A Whole Class of Bugs Disappears
Traditional SPAs duplicate state: one copy in the browser, one on the server. When they drift, debugging becomes a nightmare. HTMX makes the frontend a "dumb" interface; the server is the single source of truth. For multiplayer games like Banter Bus this is critical—players can’t tamper with scores because the authoritative state never leaves the server.
2. Zero Build Chain
HTMX is a single JS file. You can load it from a CDN, save it locally, or embed it in the Go binary via go:embed. No npm, no package.json. Updates can be automated with Renovate.
3. Friendlier to LLMs (and Humans)
LLM-written Go code I can judge; LLM-written React code I can only hope "runs".
The data flow is linear: request → endpoint → HTML response. No scattered client-side state. Both humans and LLMs can trace the entire interaction in one place.
Advanced Patterns: A Few Attributes Build Powerful Interactions
1. Search: Debounce + Shareable URLs
<input type="search" name="q" hx-get="/plants" hx-trigger="input changed delay:300ms" hx-target="#plant-list" hx-push-url="true" /> hx-trigger="input changed delay:300ms": fires 300 ms after the user stops typing (debounce). hx-push-url="true": pushes the query string into the address bar, making results shareable.
Compared to a Svelte implementation that requires reactive variables, parameter watching, manual fetch, and list maintenance, the HTMX version is dramatically simpler.
2. Lazy Loading: Show the Page First, Fill Heavy Data Later
<div hx-get="/plants/1/timeline" hx-trigger="load" 加载中...</div>After the initial page renders, HTMX automatically requests the timeline endpoint and replaces the placeholder. The server can simulate a slow query (e.g., time.Sleep(600 * time.Millisecond)) to demonstrate the effect.
3. Out-of-Band (OOB) Swaps: Update Multiple Regions from One Response
When updating a plant’s health, the list badge, the detail badge, and a toast notification all need refreshing. Instead of returning the whole page, the server sends multiple fragments with hx-swap-oob:
<span id="health-badge" hx-swap-oob="true" 健康度:良好</span>
<div id="toast" hx-swap-oob="true" 已更新</div>HTMX finds each element by ID and swaps them independently.
4. Response Headers Let the Server Redirect the Client
HX-Retarget: override the client’s target selector (e.g., send validation errors to a toast area). HX-Reswap: change the swap strategy (default is innerHTML; use outerHTML to replace the element itself).
Note: HTMX ignores 4xx/5xx response bodies by default. To show errors, either return 200 with an error fragment or adjust the htmx-config meta tag to allow swapping on 422.
5. Handling Session Expiry: HX-Redirect Instead of 302
A normal 302 redirect inside an HTMX request loads the login page into the tiny form target. The fix: detect the HX-Request header and respond with HX-Redirect:
func redirect(w http.ResponseWriter, r *http.Request, url string) {
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", url)
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, url, http.StatusSeeOther)
}6. Tiny Local Interactions with Alpine.js
Delete confirmations don’t need a round-trip. Alpine.js handles ephemeral UI state:
<div x-data="{ open: false }">
<button @click="open = true" 删除植物</button>
<div x-show="open">
<p 确定删除吗?</p>
<button hx-delete="/plants/1" 确认</button>
<button @click="open = false" 取消</button>
</div>
</div>Warning: don’t stuff large JS strings into attributes—editor support breaks and readability suffers. When the attribute soup becomes unreadable, write a proper JS function.
Go Backend: Handlers + templ + sqlc
The architecture stays the familiar three-layer Go pattern.
1. templ: Type-Safe HTML Templates
templ is built for Go and fits HTMX’s partial-HTML model. Its killer feature is full type safety with an LSP—completion, jump-to-definition, and compile-time errors in the editor.
package components
templ Timeline(entries []db.TimelineEntry) {
<div id="timeline">
for _, e := range entries {
@EntryCard(e)
}
</div>
}Run templ generate to produce Go code (marked "do not edit"). Type mismatches fail at generation time, not runtime.
Handler usage is clean:
func (h *Handler) AddEvent(w http.ResponseWriter, r *http.Request) {
entries, err := h.svc.AddEvent(r.Context(), r.PathValue("id"), r.FormValue("event_type"), r.FormValue("notes"))
if err != nil { http.Error(w, "服务异常", http.StatusInternalServerError); return }
components.Timeline(entries).Render(r.Context(), w)
}templ also supports layouts with { children... } for shared headers/footers.
Q: Why not standard library html/template ? A: It works fine; start simple. templ adds a dependency but gives type checking and LSP—worth it for larger projects.
2. sqlc: Write SQL, Get Typed Go Code
-- name: ListTimeline :many
SELECT id, plant_id, event_type, notes, created_at
FROM timeline_entries
WHERE plant_id = $1
ORDER BY created_at DESC;Run sqlc generate to get structs and query methods. The SQL is hand-written (or LLM-assisted), fully reviewable—no opaque ORM-generated JOINs.
API Design: HTML, Mobile, and Data APIs Are Three Different Things
The classic HTMX question: "I have a mobile app, do I maintain two APIs?" The speaker cites HTMX’s essay "Splitting Your Data & Application APIs" and splits APIs into three categories:
Web Application API – returns HTML fragments for HTMX.
Mobile Application API – returns JSON tailored to mobile views.
Data API – stable, versioned JSON for third parties or internal services.
Key points:
Web and mobile UIs rarely need identical data; forced reuse is "over-DRY".
Data APIs must stay stable; coupling them to web UI changes limits iteration and causes N+1 problems.
Splitting costs more boilerplate and tests—weigh the trade-off per project.
For his use case, HTML for the web and a separate data API is sufficient.
When NOT to Use HTMX
HTMX is not a silver bullet.
Heavy client-side interactivity (complex reactive logic) → React, Svelte.
Teams with dedicated frontend engineers → don’t steal their tool choices.
Need a mature design system (Storybook, component libraries) → React/Svelte ecosystems are richer.
Good fits: backend-led teams, low frontend headcount, internal admin panels, support tools, personal side projects.
On performance: his app has no real scale. First paint is faster due to HTMX’s tiny size, but "no perceptible difference"; skeleton screens and other optimizations still require effort.
Developer Experience: Making "Lazy" Productive
Task (go-task) : One task dev spins up the entire local environment.
Docker Compose : Starts Postgres and other isolated dependencies.
Air : Hot-reloads Go code on change.
templ / Tailwind watch : Auto-regenerate templates and styles.
Nix dev shell : Pins exact tool versions for every contributor, no global installs.
Playwright (Go) : End-to-end tests written in Go, keeping the stack "all Go".
An audience member noted Air can also run templ generate and sqlc generate as pre-build steps, potentially replacing separate watch tasks. The speaker hadn’t known that and suggested trying it.
LLM + Playwright CLI for debugging flaky tests "works well".
Summary
The abandoned Banter Bus wasn’t saved by a more powerful framework—it was saved by a simpler architecture:
State centralized on the backend → one whole bug category gone.
HTML partial updates → data flow is obvious.
templ + sqlc provide strong typing → easier review for humans and LLMs.
If you’re a backend engineer burned by frontend complexity, pick a small internal tool and try Go + HTMX. Simplicity is often the ultimate productivity.
References
Talk video: https://www.youtube.com/watch?v=lZ4EPkIngRc
HTMX docs: https://htmx.org
HTMX essay "Splitting Your Data & Application APIs": https://four.htmx.org/essays/splitting-your-apis
templ: https://templ.guide
sqlc: https://sqlc.dev
Alpine.js: https://alpinejs.dev
Appendix: Complete Runnable Example
A minimal "plant tracker" using only Go standard library, in-memory storage, no database. Full source: https://github.com/bigwhite/experiments/tree/master/go-htmx-2026/letsgo
Requirements
Go 1.22+ (uses r.PathValue from stdlib routing). Browser must reach unpkg and jsDelivr CDNs.
Project Structure
letsgo/
├── go.mod
├── main.go
└── templates/
├── layout.html
└── partials.htmlRun
mkdir -p letsgo/templates && cd letsgo
# save the four files below
go run .
# open http://localhost:80801. go.mod
module letsgo
go 1.22.02. main.go
(Full handler code with in-memory store, HTMX-aware redirect helper, and all routes. See source for complete listing.)
3. templates/layout.html
Base page template with htmx-config meta tag to allow swapping on 422, HTMX 2.0.4 from unpkg, Alpine.js 3.14.8 from jsDelivr, and inline CSS.
4. templates/partials.html
Defines reusable fragments: health-badge, health-badge-oob, health-badge-detail-oob, toast, error-text, detail (plant detail panel with health buttons, event form, lazy-loaded timeline), timeline, and login page.
5. Suggested Exploration Order
Search : type "薄" → list updates after ~300 ms; URL becomes /plants?q=薄. Open in new tab → full page render (same endpoint, different response).
View & Lazy Load : click "查看" → detail appears, timeline shows "加载中…" for ~600 ms ( hx-trigger="load" + server sleep).
Add Event : submit type + notes → timeline prepends new entry, toast updates, form resets.
Error Retarget : submit empty type → error appears in toast (server sets HX-Retarget: #toast).
Multi-Region Update : click "标记:枯萂" → list badge, detail badge, and toast all change via three OOB fragments.
Delete Confirm : click "删除" → Alpine.js expands confirmation inline, no network request.
Auth Redirect : click "模拟:请求受保护内容" → redirected to login via HX-Redirect; after login, back to home, protected content shows.
6. Inspecting Requests
Open DevTools Network tab: all HTMX responses are HTML fragments, not JSON. Request headers include HX-Request: true; response headers may include HX-Retarget, HX-Redirect, etc.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
TonyBai
Tony Bai's tech world (tonybai.com). Not satisfied with just "knowing how", we strive for mastery. Focused on Go language internals, high-quality engineering practices, and cloud‑native architecture, exploring cutting‑edge intersections of Go and AI. Gophers who pursue technology are welcome—follow me and evolve with Go.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
