The dashboard problem
You are building a dashboard. You need a button that refreshes a chart without reloading the page. You need a form that validates instantly and appends a row to a table. The old approach required writing a REST API, scaffolding a frontend framework, managing client-side state, and writing fetch calls to glue everything together. You spent more time configuring build tools than building features.
The alternative is Go rendering HTML and HTMX handling the interactivity. Go serves the initial page and processes requests. HTMX adds attributes to your HTML elements to trigger AJAX requests and update the DOM. You keep your business logic in Go, your rendering in templates, and you get a responsive UI with almost no JavaScript. The stack is simpler, the codebase is smaller, and the user experience is fast.
HTMX as a remote control
HTMX extends HTML with custom attributes. You add hx-post to a button, and that button gains the ability to send a POST request to the server. You add hx-target, and HTMX knows where to put the response. You add hx-swap, and HTMX knows how to inject the content. The library runs in the browser and intercepts events, makes requests, and updates the page.
Think of HTMX as a remote control for your HTML. Go is the TV that generates the picture. You press a button, HTMX tells Go to change the channel. Go sends back just the new scene. HTMX swaps the scene into the frame. The rest of the room stays the same. No full page reload. No JSON serialization. No frontend state management. Just HTML flowing from server to client.
This pattern supports progressive enhancement. If JavaScript is disabled, the links and forms still work. The browser follows the href or submits the form normally. If JavaScript is enabled, HTMX intercepts the action and provides a smoother experience. You get resilience for free.
Minimal example
Here is the simplest loop: a Go handler returns a snippet, HTMX swaps it into the page. The server renders a timestamp fragment. The client updates a div.
package main
import (
"html/template"
"net/http"
"time"
)
// tmpl holds the parsed template for the partial update.
// template.Must panics if parsing fails, which is fine for startup.
var tmpl = template.Must(template.ParseFiles("update.html"))
// updateHandler renders a timestamp fragment for HTMX to swap.
func updateHandler(w http.ResponseWriter, r *http.Request) {
// HTMX sets this header so the server can distinguish partial requests.
// This enables progressive enhancement: return full page or fragment.
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html")
}
// Pass the current time to the template.
data := map[string]string{
"time": time.Now().Format(time.Kitchen),
}
// Execute only the named template, not the full layout.
// This returns just the HTML fragment for the target element.
tmpl.ExecuteTemplate(w, "timestamp", data)
}
func main() {
http.HandleFunc("/update", updateHandler)
http.ListenAndServe(":8080", nil)
}
The template defines the fragment. The define block isolates the partial so you can reuse it.
{{define "timestamp"}}
<!-- The div wraps the content so HTMX can target the parent. -->
<!-- Classes allow CSS styling without changing structure. -->
<div class="timestamp-box">
<p>Server time: {{.time}}</p>
</div>
{{end}}
The frontend triggers the update. The button sends a POST request and replaces the content of #content.
<!DOCTYPE html>
<html>
<head>
<title>Go + HTMX</title>
<!-- Load HTMX from a CDN. No build step required. -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<div id="content">
<p>Click the button to update this section.</p>
</div>
<!-- hx-post sends a POST request to /update. -->
<!-- hx-target specifies where to put the response. -->
<!-- hx-swap replaces the inner HTML of the target. -->
<button hx-post="/update"
hx-target="#content"
hx-swap="innerHTML">
Update Content
</button>
</body>
</html>
HTMX is a remote control. Go is the TV. Keep the signal clean.
The request lifecycle
When the page loads, the browser fetches the HTML. HTMX scans the DOM for attributes. It finds hx-post="/update" on the button. It attaches an event listener to the click event.
When you click the button, HTMX intercepts the click. It prevents the default behavior. It constructs a POST request to /update. It adds HX-Request: true to the headers. It sends the request.
Go receives the request. The updateHandler function runs. It checks the HX-Request header. This check is optional but useful. If the header is present, the server knows HTMX made the request. You can return a fragment. If the header is missing, the user might have navigated directly. You can return a full page. This dual behavior makes the app robust.
The handler renders the template. It writes the HTML fragment to the response. The response flows back to the browser. HTMX receives the response. It looks at hx-target="#content". It finds the element with id="content". It replaces the inner HTML with the response. The page updates. No full reload. No flash. The user sees the new timestamp instantly.
Go functions follow a convention for receivers. The receiver name is usually one or two letters matching the type. Use (a *App) not (this *App). This keeps the code idiomatic. The community expects short receiver names.
Realistic comment section
A real application needs forms, validation, and state. Here is a comment section. Users submit comments. The server validates input. The server appends the comment. The server returns the updated list. HTMX appends the new comment to the list.
The application struct holds shared state and templates. The receiver name a matches the type App.
package main
import (
"html/template"
"net/http"
"sync"
"time"
)
// Comment represents a single user submission.
type Comment struct {
Author string
Body string
Created time.Time
}
// App holds shared state and templates.
// Embed sync.Mutex to protect concurrent access to comments.
type App struct {
sync.Mutex
comments []Comment
tmpl *template.Template
}
// NewApp initializes the application with parsed templates.
func NewApp() *App {
return &App{
tmpl: template.Must(template.ParseGlob("templates/*.html")),
}
}
The handler processes the form. It validates input. It updates state. It renders the partial.
// submitHandler processes a new comment and returns the updated list.
func (a *App) submitHandler(w http.ResponseWriter, r *http.Request) {
// Parse form data from the request body.
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Extract values. Empty strings are safe defaults.
author := r.FormValue("author")
body := r.FormValue("body")
// Validate input. Fail fast if data is missing.
// The if err != nil pattern is verbose by design.
// It makes the unhappy path visible.
if author == "" || body == "" {
http.Error(w, "author and body are required", http.StatusBadRequest)
return
}
// Lock the mutex before modifying shared state.
// Handlers run concurrently. Without a lock, this is a race condition.
a.Lock()
a.comments = append(a.comments, Comment{
Author: author,
Body: body,
Created: time.Now(),
})
a.Unlock()
// Render the partial list. HTMX expects HTML, not JSON.
// ExecuteTemplate writes directly to the response writer.
a.tmpl.ExecuteTemplate(w, "comment-list", a.comments)
}
The template renders the list. The range action iterates over comments. The template escapes HTML automatically to prevent XSS.
{{define "comment-list"}}
<!-- Range iterates over the slice of comments. -->
<!-- $c binds the current element to avoid scope issues. -->
{{range $c := .}}
<div class="comment">
<strong>{{.Author}}</strong>
<p>{{.Body}}</p>
<small>{{.Created.Format "15:04"}}</small>
</div>
{{end}}
{{end}}
The form uses hx-post and hx-swap="beforeend". The swap mode appends the response instead of replacing. This keeps existing comments and adds the new one.
<form hx-post="/submit" hx-target="#comments" hx-swap="beforeend">
<input name="author" placeholder="Name" required>
<textarea name="body" placeholder="Comment" required></textarea>
<button type="submit">Post</button>
</form>
<div id="comments">
<!-- Comments appear here -->
</div>
Don't fight the type system. Wrap the value or change the design.
Pitfalls and errors
HTMX and Go work well together, but there are traps.
Forgetting hx-target causes HTMX to swap the element with the attribute. If you put hx-post on a button inside a form, HTMX might replace the button itself. The button disappears after the first click. Always specify a target, or use hx-target="this" to swap the element itself intentionally.
XSS attacks happen when you render user input without escaping. html/template escapes automatically. If you use template.HTML, you bypass escaping. The compiler does not catch this. You must sanitize input manually. If you pass raw user input to template.HTML, the compiler rejects the program with cannot use userString (type string) as template.HTML value in argument. Use the string directly in the template to let the engine escape it.
State management requires care. Handlers run concurrently. If you store state in a global variable or a struct field without synchronization, you get race conditions. The compiler does not detect data races. Run go run -race to find them. The race detector reports WARNING: DATA RACE with stack traces. Protect shared state with sync.Mutex or channels.
Template errors surface at runtime. If you reference a field that does not exist, the template execution fails. The compiler complains with template: executing "list" at <.Field>: can't evaluate field Field in type main.Comment. Check your struct tags and field names. If you forget to define a template, you get template: "name" is undefined. Parse templates at startup with template.Must to catch errors early.
Convention matters. Run gofmt on your code. The community expects formatted code. Most editors run it on save. Do not argue about indentation. Let the tool decide. Trust gofmt. Argue logic, not formatting.
Context is plumbing. Run it through every long-lived call site. Functions that take a context should respect cancellation and deadlines. The receiver name is usually one or two letters matching the type. Use (b *Buffer) not (this *Buffer). Public names start with a capital letter. Private names start lowercase. No keywords like public or private.
The worst goroutine bug is the one that never logs.
Decision matrix
Use Go with HTMX when you want server-side rendering with dynamic updates and minimal JavaScript. Use Go with a JSON API and a frontend framework when you need complex client-side state, offline support, or a mobile app sharing the same backend. Use plain Go templates without HTMX when the application is static or requires no interactivity beyond navigation. Use WebAssembly when you need to run Go code directly in the browser for heavy computation or binary compatibility.
HTMX is a remote control. Go is the TV. Keep the signal clean.