Raw capture โ raw/how-i-use-htmx-with-go.md
url: https://www.alexedwards.net/blog/how-i-use-htmx-with-go title: "How I Use HTMX with Go" author: Alex Edwards date_fetched: 2026-07-18 date_published: 2026-06-27
Not sure how to structure your Go web application? My new book guides you through the start-to-finish build of a real world web application in Go โ covering topics like how to structure your code, manage dependencies, create dynamic database-driven pages, and how to authenticate and authorize users securely. Mid-year sale: 30% off until the end of July! Take a look! When I want to add sprinkles of interactivity to a web application, I'm a big fan of using HTMX. I like that it makes it easy to give interactions a smooth app-like feel, I like that it minimizes the amount of JavaScript that I have to write, and I like that it allows me to keep the consistency and safety of server-side HTML rendering with Go's html/template package.
In this post I'm going to run through how I typically use HTMX in conjunction with Go. Although I'm going to talk a bit about how HTMX works, the main focus is going to be on the Go side of things. Specifically:
To illustrate these things, we'll run through the build of a small application that ultimately implements a filter on a list of users.
$ 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
The author downloads HTMX locally rather than using a CDN, along with Bamboo CSS and a gopher image.
The template structure follows a base/pages/partials pattern:
assets/html
โโโ base.tmpl
โโโ pages
โ โโโ home.tmpl
โโโ partials
โโโ images.tmpl
assets/html/base.tmpl โ common HTML 'layout' markup for all web pagesassets/html/pages/ โ page-specific content for individual web pagesassets/html/partials/ โ reusable chunks of HTML markupThe base template uses Go's {{define "base"}}...{{end}} with named template actions like {{template "page:title" .}} and {{template "page:content" .}}. The colon character is used as a namespace separator.
HTMX is imported with the defer attribute so it's fetched in parallel but executed after the DOM is built.
Since Go 1.16, the author embeds HTML files and static assets using //go:embed:
//go:embed "html" "static"
var files embed.FS
var (
HTMLFiles = sub(files, "html")
StaticFiles = sub(files, "static")
)
This creates two sub-filesystems โ HTMLFiles and StaticFiles โ rooted in their respective directories, providing clear separation and eliminating path prefixes.
The core contribution of the article is the htmlRenderer type:
render() method that clones the shared template set, optionally parses additional templates, executes a named template, and writes the HTTP responsetype htmlRenderer struct {
templateFS fs.FS
sharedTemplates *template.Template
}
func newHTMLRenderer(templateFS fs.FS, sharedTemplateFiles ...string) (*htmlRenderer, error) { ... }
func (h *htmlRenderer) render(w http.ResponseWriter, status int, data any, templateName string, additionalTemplateFiles ...string) error { ... }
The render() method can send either complete HTML pages (by executing the "base" template) or specific partials (by executing a named partial template) โ using the same function for both.
In the handler, rendering a partial is as simple as:
err := app.html.render(w, http.StatusOK, width, "partial:image:gopher")
Because all partials are already in the shared template set, no additional file paths are needed.
Requests from HTMX include an HX-Request: true header. The author creates a helper:
func isHTMXRequest(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
This allows handlers to return either a full page or a partial based on the request source, so direct URL visits (e.g., sharing a search link) get a full HTML page instead of a raw partial.
The Vary: HX-Request header is set on all responses to tell caches that responses may differ based on this header.
Browsers automatically follow 3xx responses before HTMX can see them. The solution is the HX-Redirect header with a 2xx response:
func redirect(w http.ResponseWriter, r *http.Request, url string, code int) {
if isHTMXRequest(r) {
w.Header().Set("HX-Redirect", url)
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, url, code)
}
HX-Redirect triggers a full-page reload. The alternative HX-Location avoids the reload but has a problem: the handler can't distinguish between an HTMX request following an HX-Location redirect (should return full page) and a normal HTMX request (should return partial).
By default, HTMX doesn't swap in 4xx/5xx responses โ it logs errors to the console. The author configures responseHandling to change this:
<body> element (full-page error display)HTMX caches pages in localStorage for history navigation. If there's a cache miss, HTMX re-fetches โ but with HX-Request: true, which would return a partial. The fix: historyRestoreAsHxRequest: false.
The author's recommended defaults:
{
"includeIndicatorStyles": false,
"historyCacheSize": 0,
"historyRestoreAsHxRequest": false,
"responseHandling": [
{"code": "204", "swap": false},
{"code": "422", "swap": true},
{"code": "[45]..", "swap": true, "target": "body"},
{"code": "...", "swap": true}
],
"timeout": 5000
}
includeIndicatorStyles: false โ define indicator styles alongside other CSShistoryCacheSize: 0 โ disable localStorage caching entirely (simpler, fewer bugs; will be the default in future HTMX versions)disableInheritance: true โ prefer explicit HTMX attribute declarationstimeout: 5000 โ project-specific default timeoutFor larger applications, a "layout" template can sit between the base template and page content:
func (app *application) adminOrders(w http.ResponseWriter, r *http.Request) {
err := app.html.render(w, 200, nil, "base", "layouts/admin.tmpl", "pages/admin-orders.tmpl")
...
}
HTMX sends the user's actual browser URL in the HX-Current-URL header, which may differ from r.URL in Go handlers. A helper parses it:
func browserURL(r *http.Request) (*url.URL, error) {
cu := r.Header.Get("HX-Current-URL")
if cu != "" {
return url.Parse(cu)
}
return r.URL, nil
}