Render HTML on the server, ship fragments over the wire. Where Go is genuinely useful for the web.
Last verified: 2026-07-04
Go's web sweet spot is server-driven hypermedia — render HTML server-side, return fragments, let htmx swap them by attribute. No JSON API, no client-side store to keep in sync. It plays to Go's real strengths: fast net/http, goroutine-per-request, one static binary, zero JS build.
Go was built for network services that emit bytes fast. An HTML page is just bytes. Shipping HTML fragments in response to user actions is squarely inside that lane. Four properties do the work:
| Property | What it buys you |
|---|---|
net/http is fast and boring | Production-grade HTTP server in the stdlib. No framework tax. |
| Goroutine-per-request | The concurrency model is the request handler. No async coloring, no event loop. A blocking DB call is just a blocking call — the runtime schedules around it. |
| Single static binary | go build gives one file. Copy it to a box, run it. No node_modules, no runtime, no interpreter. |
| No JS build pipeline | No webpack/vite/esbuild, no npm install supply-chain surface, no transpile step. Server emits HTML; browser renders it. |
A CRUD app + dashboard in Go is a handler, a template, and a fragment. You hold the whole request lifecycle in your head. The moment you fight Go to build a client-side reactive app, you've left the lane — that's what the boundary at the bottom is about.
The core idea is old, proven, pre-SPA — how the web worked before 2010, refined:
hx-get, hx-post, hx-target, hx-swap) declare what triggers a request and where the returned HTML goes. No hand-written fetch/render/reconcile code.This drops the classic SPA stack — no JSON API layer, no client router, no client-side view models, no serialization boundary where bugs breed. One language (Go), one render path (server → HTML), one state store (the DB).
Every version below pulled live from the GitHub Releases API on 2026-07-04.
| Tool | Version | What it is / when to reach for it |
|---|---|---|
| templ | v0.3.1020 | Typed, compiled Go components. Write .templ files (HTML-ish with { go exprs }); templ generate compiles them to type-safe Go functions — wrong prop type is a compile error. The de-facto choice for server-rendered Go in 2026. Cost: a codegen step. |
| htmx | 2.0.10 stable | Hypermedia over the wire. ~14kB, no deps, drop in a <script>. Extends HTML so any element can issue any HTTP verb and swap the response. Ship 2.x in prod — 4.0.0-beta5 (2026-06-26) is feature-complete but bug-fixing; stable 4.0 expected early 2027. Don't start a new prod app on a beta. |
| gomponents | v1.3.0 | Pure-Go HTML: Div(Class("card"), H1(Text("hi"))). No DSL, no template files, no codegen. Because it's ordinary Go it's the LLM-safest — models generate valid gomponents more reliably than templ syntax (no separate grammar to get wrong). Cost: more verbose; designers can't hand you a file to edit. |
| Datastar | v1.0.2 | Hypermedia plus fine-grained reactive signals in one small script, built around SSE — the server streams multiple DOM patches + signal updates down one long-lived connection. Reach for it when live server-push (self-updating dashboards, progress, feeds) is the actual requirement. |
| templui | v1.12.1 | 30+ prebuilt components (buttons, cards, modals, charts, forms) for templ, styled with Tailwind, Alpine.js for sprinkle interactivity. CLI-install (copies source into your repo) or import. Still evolving — may break pre-stable. A good accelerator over bare templ. |
| html/template | stdlib | The floor. Context-aware auto-escaping (knows HTML vs JS vs URL) — XSS-safe by default, which is why it beats text/template for web. Zero deps, always present. Cost: no compile-time type checking of template data; weaker composition than templ/gomponents. |
Simplest complete stack: html/template + htmx 2.x — dependency-light, no codegen. Want typed components: templ. Want LLM-friendly / refactor-safe markup: gomponents. Want live server-push: Datastar.
The whole loop — a page with a button that swaps in a fragment from the server.
components.templpackage main
import (
"fmt"
"strconv"
)
// Full page shell — includes htmx once.
templ Page() {
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/htmx.org@2.0.10"></script>
</head>
<body>
<h1>Counter</h1>
<div id="count">
@Counter(0)
</div>
</body>
</html>
}
// The fragment. htmx swaps THIS in on click.
templ Counter(n int) {
<div>
<span>Count: { strconv.Itoa(n) }</span>
<button
hx-post="/increment"
hx-vals={ fmt.Sprintf(`{"n": %d}`, n) }
hx-target="#count"
hx-swap="innerHTML"
>
+1
</button>
</div>
}
Run templ generate to produce components_templ.go.
main.gopackage main
import (
"fmt"
"net/http"
"strconv"
)
func main() {
http.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
Page().Render(r.Context(), w) // full page
})
http.HandleFunc("POST /increment", func(w http.ResponseWriter, r *http.Request) {
n, _ := strconv.Atoi(r.FormValue("n"))
Counter(n + 1).Render(r.Context(), w) // FRAGMENT only
})
http.ListenAndServe(":8080", nil)
}
First GET returns the whole Page. Clicking +1 fires hx-post="/increment"; the handler renders only the Counter fragment and writes it to the response; htmx swaps it into #count. No JSON API, no manual DOM code — each click round-trips the new HTML. (In a real app the count lives in the DB; here it round-trips via hx-vals to keep the example one-file.)
Same shape scales to real work: POST /todos returns the new <li>, DELETE /todos/{id} returns empty (row vanishes), GET /search?q= returns the results table. Every handler is parse input → query DB → render fragment.
| Use case | Why server-driven Go fits |
|---|---|
| Internal tools / admin | CRUD forms + tables. htmx handles inline edit, delete, pagination with fragment swaps. No SPA overhead for a 20-user tool. |
| Dashboards | Server renders the numbers; htmx polls (hx-trigger="every 5s") or Datastar streams via SSE. State stays in the DB. |
| CRUD apps | The archetype. Handler → query → render fragment. Whole feature in one file. |
| Content / SEO pages | Crawlers get full markup on first byte — no hydration wait, no client-render blank page, fast first paint. |
| Small teams / solo | One language, one binary, one mental model. No frontend/backend split to staff. |
The server already has the data and the auth context, so rendering there — not shipping JSON to reassemble client-side — is the shorter path.
Server-driven Go is the right tool for the cases above. It is not the right tool for rich offline apps (local-first, no network), heavy client-local state (a complex editor, a design canvas, a spreadsheet with thousands of live cells), or real-time collaborative editing (multiplayer cursors, CRDT merge). There the round-trip and server-owned-state model breaks down — you genuinely need a client runtime.
Reach for a real SPA — and build it in Rust (WASM) or TypeScript, not Go-WASM. Go compiled to WASM is superseded here: large bundles, GC pauses, awkward DOM interop. Go's win is on the server; don't drag it into the browser to fight a battle other tools win. Fuller accounting in the golang hub → #ui section.