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 content
  • partials/ — 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:

  1. Parses shared templates (base + all partials) at startup
  2. Clones the shared set for each request
  3. Optionally parses additional page-specific templates
  4. 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.