First Page
A complete htmx page backed by Go’s standard library: a searchable contacts table with delete buttons. One file, no dependencies beyond htmx itself. Requires Go 1.22+ (for ServeMux method and path patterns).
Install htmx
htmx is one script tag in your page shell:
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js"></script>
The app
Save as main.go:
package main
import (
"html/template"
"log"
"net/http"
"strconv"
"strings"
)
type contact struct {
ID int
First string
Last string
Email string
}
var contacts = []contact{
{1, "Amina", "Otieno", "amina@example.com"},
{2, "Brian", "Kimani", "brian@example.com"},
{3, "Ciku", "Njoroge", "ciku@example.com"},
{4, "David", "Wafula", "david@example.com"},
{5, "Esther", "Mwangi", "esther@example.com"},
}
var tmpl = template.Must(template.New("").Parse(`
{{define "rows"}}
{{range .}}
<tr id="contact-{{.ID}}">
<td>{{.First}}</td>
<td>{{.Last}}</td>
<td>{{.Email}}</td>
<td><button hx-delete="/contacts/{{.ID}}" hx-target="closest tr" hx-swap="outerHTML">Delete</button></td>
</tr>
{{end}}
{{end}}
{{define "page"}}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Contacts</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js"></script>
<style>
body { font-family: sans-serif; margin: 2rem; }
table { border-collapse: collapse; }
td, th { border: 1px solid #ccc; padding: 0.4rem 0.8rem; }
</style>
</head>
<body>
<h1>Contacts</h1>
<form action="/contacts" method="get">
<input type="search" name="q" placeholder="Search..."
hx-get="/contacts"
hx-trigger="input changed delay:500ms, search"
hx-target="#search-results" />
</form>
<table>
<thead>
<tr><th>First</th><th>Last</th><th>Email</th><th></th></tr>
</thead>
<tbody id="search-results">
{{template "rows" .}}
</tbody>
</table>
</body>
</html>
{{end}}
`))
func filter(q string) []contact {
q = strings.ToLower(q)
var matches []contact
for _, c := range contacts {
if strings.Contains(strings.ToLower(c.First), q) ||
strings.Contains(strings.ToLower(c.Last), q) ||
strings.Contains(strings.ToLower(c.Email), q) {
matches = append(matches, c)
}
}
return matches
}
func home(w http.ResponseWriter, r *http.Request) {
tmpl.ExecuteTemplate(w, "page", contacts)
}
func search(w http.ResponseWriter, r *http.Request) {
matches := filter(r.URL.Query().Get("q"))
w.Header().Set("Vary", "HX-Request")
// Browsers without JS submit the form normally and get the full page
// back; htmx requests get just the tbody rows.
if r.Header.Get("HX-Request") == "true" {
tmpl.ExecuteTemplate(w, "rows", matches)
return
}
tmpl.ExecuteTemplate(w, "page", matches)
}
func delContact(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
var kept []contact
for _, c := range contacts {
if c.ID != id {
kept = append(kept, c)
}
}
contacts = kept
// 200 with an empty body: htmx replaces the row with nothing.
w.WriteHeader(http.StatusOK)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", home)
mux.HandleFunc("GET /contacts", search)
mux.HandleFunc("DELETE /contacts/{id}", delContact)
log.Println("listening on http://localhost:8080")
log.Fatal(http.ListenAndServe("localhost:8080", mux))
}
Run it:
go run main.go
Open http://localhost:8080. Typing in the search box filters the table without a reload; Delete removes a row.
The three responses
One template file, two render targets. Each handler serves a different slice of it:
| Handler | Trigger | Response |
|---|---|---|
home | GET / | 200 full page: shell + all rows via template "rows" |
search | GET /contacts?q=mi with HX-Request: true | 200 fragment: just the matching <tr>s, swapped into #search-results (innerHTML default) |
search | GET /contacts?q=mi without the header (form submit) | 200 full page: same handler, non-JS path |
delContact | DELETE /contacts/2 | 200 empty body: with hx-swap="outerHTML" the target row is replaced with nothing |
The fragment and the full page come from the same rows template - the shell template embeds it, and the fragment handler executes it directly. That one-template trick is most of what a Go + htmx backend needs.
Progressive enhancement
The search input already works without JavaScript, because it sits in a real <form action="/contacts" method="get">. The search trigger covers the Enter key for JS users; for no-JS users the form submit does the same request without the HX-Request header, and the handler returns the full page. This is the pattern the htmx docs recommend: htmx upgrades a working HTML form, it does not replace it.
Cache headers
Gotcha:
/contactsreturns two different bodies depending onHX-Request. Without help, a browser cache can store one and serve it for the other. TheVary: HX-Requestresponse header insearchkeys the cache on that header too. If you also useETags, generate different ones per variant.
Next: Common Patterns builds on this skeleton with click-to-edit, tabs, and multi-region updates.