HTMX with Go: Template Patterns and Partial Responses
Alex Edwards, author of "Let's Go" and "Let's Go Further," published a detailed guide on integrating HTMX with Go. The post covers template organization, partial vs full-page HTML responses, redirect handling, and configuration settings. It uses a demo application that filters a list of users.
Project Structure and Setup
Edwards starts with a clean project skeleton:
go mod init example.com/htmx
mkdir -p assets/static/css assets/static/img assets/static/js assets/html/partials assets/html/pages cmd/web
touch assets/efs.go assets/html/base.tmpl assets/html/partials/images.tmpl assets/html/pages/home.tmpl cmd/web/main.go cmd/web/handlers.go cmd/web/html.go
HTMX is downloaded as a static file (version 2.0.4 at the time of writing) and served locally, avoiding CDN dependencies. The same goes for Bamboo CSS (a classless framework) and a gopher image.
Template Organization
Edwards uses a three-tier template structure:
base.tmpl— the common HTML layout (DOCTYPE, head, body)pages/— page-specific contentpartials/— reusable HTML chunks
Templates are explicitly named using {{define "name"}}...{{end}} for consistency. The base template includes HTMX with the defer attribute:
This ensures HTMX loads in parallel with HTML parsing but executes only after the DOM is ready.
Serving Static and Embedded Files
Go 1.16+ embed is used to bundle HTML and static assets into the binary. Edwards creates two sub-filesystems (HTMLFiles and StaticFiles) from a single embed.FS:
//go:embed "html" "static"
var files embed.FS
var (
HTMLFiles = sub(files, "html")
StaticFiles = sub(files, "static")
)
func sub(f embed.FS, dir string) fs.FS {
sub, err := fs.Sub(f, dir)
if err != nil {
panic(err)
}
return sub
}
This keeps concerns separate and avoids prefix paths when opening files.
The htmlRenderer Pattern
The core of the Go-HTML integration is an htmlRenderer type that:
- Parses shared templates (base + all partials) at startup
- Clones the shared set for each request
- Optionally parses additional page-specific templates
- Executes the named template and writes the response
type htmlRenderer struct {
templateFS fs.FS
sharedTemplates *template.Template
}
func newHTMLRenderer(templateFS fs.FS, sharedTemplateFiles ...string) (*htmlRenderer, error) { funcs := template.FuncMap{ "now": time.Now, } sharedTemplates, err := template.New("").Funcs(funcs).ParseFS(templateFS, sharedTemplateFiles...) if err != nil { return nil, err } return &htmlRenderer{templateFS: templateFS, sharedTemplates: sharedTemplates}, nil }
func (h *htmlRenderer) render(w http.ResponseWriter, status int, data any, templateName string, additionalTemplateFiles ...string) error { ts, err := h.sharedTemplates.Clone() if err != nil { return err } if len(additionalTemplateFiles) > 0 { ts, err = ts.ParseFS(h.templateFS, additionalTemplateFiles...) if err != nil { return err } } buf := new(bytes.Buffer) err = ts.ExecuteTemplate(buf, templateName, data) if err != nil { return err } w.WriteHeader(status) buf.WriteTo(w) return nil }
Cloning the shared template set for each request prevents data races and allows safe concurrent execution.
### HTMX Attributes in Practice
In the home page template, a button triggers a GET request and replaces itself:
```html
Wanna see a cute gopher?
When clicked, HTMX sends a GET to /gopher. The server can respond with either a partial (just the image) or a full page. Edwards prefers to check the HX-Request header to decide:
func (app *application) gopher(w http.ResponseWriter, r *http.Request) {
data := map[string]any{"Width": 200}
if r.Header.Get("HX-Request") == "true" {
// Return partial response
err := app.html.render(w, 200, data, "partial:image:gopher", "partials/images.tmpl")
if err != nil {
app.logger.Error(err.Error())
http.Error(w, http.StatusText(500), 500)
}
return
}
// Return full page
err := app.html.render(w, 200, data, "base", "pages/gopher.tmpl", "partials/images.tmpl")
if err != nil {
app.logger.Error(err.Error())
http.Error(w, http.StatusText(500), 500)
}
}
This dual-mode pattern ensures the application works even without JavaScript (graceful degradation).
Redirects and Error Handling
HTMX handles redirects via the HX-Redirect response header. Edwards recommends setting it explicitly in Go handlers:
func (app *application) someHandler(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/new-location")
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, "/new-location", http.StatusSeeOther)
}
For errors, HTMX supports HX-Retarget and HX-Reswap to display error messages in a specific element. Edwards shows how to return a partial error template.
Configuration Settings
Edwards recommends setting hx-boost on the body element to enable full-page navigation via AJAX:
He also sets hx-history to false to avoid HTMX's history cache interfering with server-side state, and uses hx-push-url only when necessary.
Building the Demo
The demo application filters a list of users by name. The filter input has hx-get="/users" hx-trigger="keyup changed delay:500ms" hx-target="#user-list" hx-swap="innerHTML". The server returns a partial HTML table of users matching the query. The full source code is available on GitHub.
Conclusion
Edwards' approach emphasizes server-side rendering with HTMX as an enhancement layer. The key takeaway: use partial templates for HTMX responses, check the HX-Request header to decide response type, and always provide a fallback for non-JS clients.

