Common Patterns

Each pattern is the HTML you serve plus the handler that answers it. Handlers assume the skeleton from First Page (a parsed tmpl with named templates). Every pattern links the canonical client-side example in the htmx repo.

Debounce on the input event; keep the form wrapper so Enter still works without JS:

<form action="/contacts" method="get">
  <input type="search" name="q"
         hx-get="/contacts"
         hx-trigger="input changed delay:500ms, search"
         hx-target="#results"
         hx-sync="closest form:abort">
</form>

<table>
  <tbody id="results"><!-- rows land here --></tbody>
</table>
func search(w http.ResponseWriter, r *http.Request) {
	tmpl.ExecuteTemplate(w, "rows", filterContacts(r.URL.Query().Get("q")))
}

Response: 200 with the matching <tr> fragment; it replaces #results content.

delay:500ms resets while the user types; changed skips no-op requests. hx-sync="closest form:abort" aborts an in-flight search when a newer one starts, so a slow early response cannot overwrite a fresh one - the same discipline the bulk-update example applies to checkbox batches. To signal an empty result set to other parts of the page, add an HX-Trigger response header.

Example: active-search.md

Delete row

Hoist the shared config to the tbody and let buttons inherit it:

<tbody hx-confirm="Are you sure?"
       hx-target="closest tr"
       hx-swap="outerHTML swap:1s">
  <tr>
    <td>brian@example.com</td>
    <td><button hx-delete="/contacts/2">Delete</button></td>
  </tr>
</tbody>
tr.htmx-swapping td {
  opacity: 0;
  transition: opacity 1s ease-out;
}
func delContact(w http.ResponseWriter, r *http.Request) {
	deleteContact(r.PathValue("id"))
	w.WriteHeader(http.StatusOK) // empty body: the row is replaced with nothing
}

Response: 200, empty body. The swap:1s delay gives the CSS fade time to play before removal.

Example: delete-row.md

Click to edit

Swap a read-only row for a form, then swap back on save. The container targets itself with outerHTML, so whichever fragment the server returns becomes the new state:

<div hx-target="this" hx-swap="outerHTML">
  <div><label>First</label>: Amina</div>
  <div><label>Email</label>: amina@example.com</div>
  <button hx-get="/contacts/1/edit">Edit</button>
</div>
func editForm(w http.ResponseWriter, r *http.Request) {
	tmpl.ExecuteTemplate(w, "contact-form", findContact(r.PathValue("id")))
}

func saveContact(w http.ResponseWriter, r *http.Request) {
	c := updateContact(r.PathValue("id"), r.FormValue("first"), r.FormValue("email"))
	tmpl.ExecuteTemplate(w, "contact-view", c)
}

Responses: the edit handler returns 200 with the form fragment (its own hx-put and a cancel hx-get); the save handler returns 200 with the read-only view again. State lives on the server; the client just alternates two fragments.

Example: click-to-edit.md

Tabs

Tab state is server state: the server renders the tab list with the active one highlighted, and every button targets the same pane:

<div id="tabs" hx-get="/tabs/today" hx-trigger="load" hx-target="this" hx-swap="innerHTML"></div>
<div class="tab-list" role="tablist">
  <button hx-get="/tabs/today" class="selected" role="tab">Today</button>
  <button hx-get="/tabs/week" role="tab">This week</button>
</div>
<div id="tab-content" role="tabpanel">...</div>
// route: mux.HandleFunc("GET /tabs/{name}", tab)
func tab(w http.ResponseWriter, r *http.Request) {
	tmpl.ExecuteTemplate(w, "tabs", r.PathValue("name"))
}

Response: 200 with the tab list plus the selected pane; it replaces the contents of #tabs, including the aria-selected state. Bookmarking a tab needs hx-push-url - and remember that pushed URLs must serve a full page.

Example: tabs-hateoas.md

Lazy load

Put a placeholder in the page and fetch the real content when it scrolls into view:

<div hx-get="/graph" hx-trigger="revealed once">
  <img class="htmx-indicator" src="/spinner.svg" alt="Loading">
</div>
func graph(w http.ResponseWriter, r *http.Request) {
	tmpl.ExecuteTemplate(w, "graph", nil)
}

Response: 200 with the chart fragment, which replaces the placeholder. revealed fires once when the element enters the viewport; inside a scrollable container use intersect once instead. The htmx example uses load for above-the-fold content - same handler, different trigger.

Example: lazy-load.md

Update other content

A form posts a row and the same response updates a counter elsewhere, via an hx-partial envelope:

<form hx-post="/contacts" hx-target="#contacts-table" hx-swap="beforeend">
  <input name="first" placeholder="First">
  <input name="email" placeholder="Email">
  <button>Add</button>
</form>

<span id="contact-count">5 contacts</span>
func addContact(w http.ResponseWriter, r *http.Request) {
	c := insertContact(r.FormValue("first"), r.FormValue("email"))
	tmpl.ExecuteTemplate(w, "contact-row", c)
	fmt.Fprintf(w, "<hx-partial id=\"contact-count\">%d contacts</hx-partial>", len(contacts))
}

Response: 200 with two pieces. The <tr> appends to #contacts-table (the main swap); the envelope tells htmx to replace #contact-count with the new text, then the envelope itself is discarded. The id attribute is shorthand for hx-target="#contact-count".

For “replace this id” updates, out-of-band swaps do the same job - just remember the <template> wrapper when the payload is a table row. See Server Integration.

Example: update-other-content.md

More patterns live in the repo’s examples directory, including infinite scroll, inline validation, and dialogs: www/content/examples/.