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:

HandlerTriggerResponse
homeGET /200 full page: shell + all rows via template "rows"
searchGET /contacts?q=mi with HX-Request: true200 fragment: just the matching <tr>s, swapped into #search-results (innerHTML default)
searchGET /contacts?q=mi without the header (form submit)200 full page: same handler, non-JS path
delContactDELETE /contacts/2200 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: /contacts returns two different bodies depending on HX-Request. Without help, a browser cache can store one and serve it for the other. The Vary: HX-Request response header in search keys the cache on that header too. If you also use ETags, generate different ones per variant.

Next: Common Patterns builds on this skeleton with click-to-edit, tabs, and multi-region updates.