Templ With HTMX for Interactive Pages Without a SPA
Build a contacts directory with templ and HTMX: live search, endless scroll, click to edit with validation, delete with an out of band count and fragments.

HTMX makes a server rendered application interactive by letting any element make a request and swap in the HTML that comes back. The hard part of a real page, as opposed to a demo, is the number of different HTML shapes the server must return: the whole page, the results table alone, additional rows to append, a single row, an edit form for one row. With string templates these become a pile of named templates that must be kept consistent. With templ they are components in one file, and the library can render the exact part you need.
Tested with Go 1.25.6, templ v0.3.1001 and htmx 2.x attributes, macOS on Apple Silicon (arm64)
This project builds a contacts directory with live search, endless scrolling, click-to-edit rows with validation, and delete with an out-of-band counter, then tests the contract between the shapes. It follows the tutorial, builds on layouts, and covers the same ground as the html/template dashboard with typed components and fragments. The next application in the series is a todo app. It belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6 with templ v0.3.1001; the attributes follow the HTMX documentation.
The components
package main
import (
"fmt"
"net/url"
)
type Contact struct {
ID int
Name string
Email string
City string
}
type ListView struct {
Query string
Rows []Contact
Total int
Next int // the next page number, or 0 when this was the last page
}
type Errs map[string]string
func moreURL(v ListView) string {
q := url.Values{"page": {fmt.Sprint(v.Next)}}
if v.Query != "" {
q.Set("q", v.Query)
}
return "/contacts?" + q.Encode()
}
templ Layout(title string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>{ title }</title>
<script src="/static/htmx.min.js" defer></script>
</head>
<body>
{ children... }
</body>
</html>
}
templ Page(v ListView) {
@Layout("Contacts") {
<h1>
Contacts
@Count(v.Total, false)
</h1>
<input
type="search"
name="q"
value={ v.Query }
placeholder="Search name, email or city"
hx-get="/contacts"
hx-trigger="input changed delay:300ms, search"
hx-target="#results"
hx-push-url="true"
/>
@templ.Fragment("results") {
<table id="results">
<tbody>
@Rows(v)
</tbody>
</table>
}
}
}
// Count is also sent out of band after a delete, so the heading stays right without a second request.
templ Count(n int, oob bool) {
<span
id="count"
if oob {
hx-swap-oob="true"
}
>({ fmt.Sprint(n) } shown)</span>
}
templ Rows(v ListView) {
if len(v.Rows) == 0 {
<tr><td>No contacts match "{ v.Query }".</td></tr>
}
for i, c := range v.Rows {
if i == len(v.Rows)-1 && v.Next > 0 {
@Row(c, moreURL(v))
} else {
@Row(c, "")
}
}
}
// Row is one contact. The last row of a page carries hx-trigger="revealed", which loads the next page when it scrolls into view.
templ Row(c Contact, loadMore string) {
<tr
id={ fmt.Sprintf("contact-%d", c.ID) }
if loadMore != "" {
hx-get={ loadMore }
hx-trigger="revealed"
hx-swap="afterend"
}
>
<td>{ c.Name }</td>
<td>{ c.Email }</td>
<td>{ c.City }</td>
<td>
<button hx-get={ fmt.Sprintf("/contacts/%d/edit", c.ID) } hx-target="closest tr" hx-swap="outerHTML">Edit</button>
<button hx-delete={ fmt.Sprintf("/contacts/%d", c.ID) } hx-target="closest tr" hx-swap="outerHTML" hx-confirm="Delete this contact?">Delete</button>
</td>
</tr>
}
templ EditRow(c Contact, errs Errs) {
<tr id={ fmt.Sprintf("contact-%d", c.ID) }>
<td colspan="4">
<form hx-put={ fmt.Sprintf("/contacts/%d", c.ID) } hx-target="closest tr" hx-swap="outerHTML">
<input name="name" value={ c.Name } aria-invalid={ fmt.Sprint(errs["name"] != "") }/>
if errs["name"] != "" {
<span class="error" role="alert">{ errs["name"] }</span>
}
<input name="email" value={ c.Email } aria-invalid={ fmt.Sprint(errs["email"] != "") }/>
if errs["email"] != "" {
<span class="error" role="alert">{ errs["email"] }</span>
}
<input name="city" value={ c.City }/>
<button>Save</button>
<button hx-get={ fmt.Sprintf("/contacts/%d", c.ID) } hx-target="closest tr" hx-swap="outerHTML">Cancel</button>
</form>
</td>
</tr>
}A few lines need explaining before the server code.
templ.Fragment("results") { ... } marks a region of the component with an identifier. When the whole page is rendered, the fragment is rendered in place and nobody notices. When the handler asks for that fragment alone, only its content is written. That is the feature that replaces a separate "results" template: the table is defined once, inside the page, and a request from the search box gets just that part.
The search input carries the HTMX behavior: hx-get="/contacts" with the input's own name and value sent as q, hx-trigger="input changed delay:300ms, search" firing 300 ms after the user stops typing and when they clear the field, hx-target="#results" replacing the table, and hx-push-url="true" so the address bar shows /contacts?q=lima and the Back button works.
The last row of a page carries three extra attributes through a conditional block inside the tag: hx-get pointing at the next page, hx-trigger="revealed" to fire when the row scrolls into view, and hx-swap="afterend" to insert the new rows after it. Which row gets them is decided in Rows, which passes the load-more URL to exactly one Row and an empty string to the others. moreURL builds the query string with url.Values, so a search term with special characters is encoded properly.
Count has an oob parameter. As an ordinary component it prints the number in the heading. With oob true it adds hx-swap-oob="true", which tells HTMX to find the element with the same id in the current page and replace it, whatever the original target was.
One syntax detail cost me a run. My first version of the heading was <h1>Contacts @Count(v.Total, false)</h1> on one line, and the output check for the count failed: templ treats an @ call as a component call only at the start of a line inside an element, so inline it was literal text. Put component calls on their own lines.
The server
package main
import (
"net/http"
"slices"
"strconv"
"strings"
"sync"
"github.com/a-h/templ"
)
const pageSize = 5
type App struct {
mu sync.Mutex
contacts []Contact
}
func NewApp() *App {
cities := []string{"Lisbon", "Oslo", "Kyoto", "Lima"}
a := &App{}
for i := 1; i <= 12; i++ {
a.contacts = append(a.contacts, Contact{ID: i, Name: "Person " + strconv.Itoa(i), Email: "p" + strconv.Itoa(i) + "@example.test", City: cities[i%4]})
}
a.contacts[2].Name = "Ada <b>Lovelace</b>" // hostile-looking data must stay text
return a
}
func (a *App) filter(q string) []Contact {
q = strings.ToLower(q)
var out []Contact
for _, c := range a.contacts {
if q == "" || strings.Contains(strings.ToLower(c.Name+" "+c.Email+" "+c.City), q) {
out = append(out, c)
}
}
return out
}
func isHX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true" && r.Header.Get("HX-Boosted") != "true"
}
func (a *App) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /contacts", a.list)
mux.HandleFunc("GET /contacts/{id}", a.show)
mux.HandleFunc("GET /contacts/{id}/edit", a.edit)
mux.HandleFunc("PUT /contacts/{id}", a.update)
mux.HandleFunc("DELETE /contacts/{id}", a.remove)
return vary(mux)
}
func vary(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "HX-Request")
next.ServeHTTP(w, r)
})
}
func (a *App) list(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
page = max(page, 1)
a.mu.Lock()
all := a.filter(q)
a.mu.Unlock()
start := min((page-1)*pageSize, len(all))
end := min(start+pageSize, len(all))
v := ListView{Query: q, Rows: all[start:end], Total: len(all)}
if end < len(all) {
v.Next = page + 1
}
switch {
case page > 1 && isHX(r):
templ.Handler(Rows(v)).ServeHTTP(w, r) // more rows, appended after the last one
case isHX(r):
templ.Handler(Page(v), templ.WithFragments("results")).ServeHTTP(w, r) // just the results table
default:
templ.Handler(Page(v)).ServeHTTP(w, r) // a normal page load, or JavaScript disabled
}
}
func (a *App) find(r *http.Request) (int, Contact, bool) {
id, _ := strconv.Atoi(r.PathValue("id"))
a.mu.Lock()
defer a.mu.Unlock()
i := slices.IndexFunc(a.contacts, func(c Contact) bool { return c.ID == id })
if i < 0 {
return 0, Contact{}, false
}
return i, a.contacts[i], true
}
func (a *App) show(w http.ResponseWriter, r *http.Request) {
if _, c, ok := a.find(r); ok {
templ.Handler(Row(c, "")).ServeHTTP(w, r)
return
}
http.NotFound(w, r)
}
func (a *App) edit(w http.ResponseWriter, r *http.Request) {
if _, c, ok := a.find(r); ok {
templ.Handler(EditRow(c, nil)).ServeHTTP(w, r)
return
}
http.NotFound(w, r)
}
func (a *App) update(w http.ResponseWriter, r *http.Request) {
i, c, ok := a.find(r)
if !ok {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
c.Name, c.Email, c.City = strings.TrimSpace(r.PostForm.Get("name")), strings.TrimSpace(r.PostForm.Get("email")), strings.TrimSpace(r.PostForm.Get("city"))
errs := Errs{}
if c.Name == "" {
errs["name"] = "Name is required"
}
if !strings.Contains(c.Email, "@") {
errs["email"] = "Email must contain @"
}
if len(errs) > 0 {
templ.Handler(EditRow(c, errs), templ.WithStatus(http.StatusUnprocessableEntity)).ServeHTTP(w, r)
return
}
a.mu.Lock()
a.contacts[i] = c
a.mu.Unlock()
templ.Handler(Row(c, "")).ServeHTTP(w, r)
}
func (a *App) remove(w http.ResponseWriter, r *http.Request) {
i, _, ok := a.find(r)
if !ok {
http.NotFound(w, r)
return
}
a.mu.Lock()
a.contacts = slices.Delete(a.contacts, i, i+1)
n := len(a.contacts)
a.mu.Unlock()
// The row is removed by swapping an empty response in; the heading count rides along out of band.
templ.Handler(Count(n, true)).ServeHTTP(w, r)
}The list handler is where the three response shapes are chosen. For page > 1 it renders Rows, so the response is only <tr> elements for HTMX to place after the trigger row. For an ordinary HTMX search it renders the whole Page with templ.WithFragments("results"), which writes only the table. For a normal request it renders the whole page, so the URL works when opened directly, bookmarked or loaded without JavaScript. vary adds Vary: HX-Request for the reason given in the HTMX dashboard article: the same URL gives different bodies depending on the header.
The edit flow shows the status codes. GET /contacts/3/edit returns the form row. PUT /contacts/3 validates, and on failure answers 422 Unprocessable Entity with the same form, the user's input preserved in the value attributes, and an error message next to each bad field; on success it returns the ordinary Row, which replaces the form. DELETE returns only the Count element marked out-of-band: the response has no row content, so the targeted row is replaced by nothing and disappears, and the heading count changes in the same response.
Exercising it
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
)
func main() {
srv := httptest.NewServer(NewApp().Routes())
defer srv.Close()
do := func(method, path string, hx bool, form url.Values) (*http.Response, string) {
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req, _ := http.NewRequest(method, srv.URL+path, body)
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if hx {
req.Header.Set("HX-Request", "true")
}
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
return res, string(b)
}
rows := func(s string) int { return strings.Count(s, "<tr id=") }
ids := regexp.MustCompile(`<tr id="contact-(\d+)"`)
list := func(s string) string {
var out []string
for _, m := range ids.FindAllStringSubmatch(s, -1) {
out = append(out, m[1])
}
return strings.Join(out, ",")
}
_, body := do("GET", "/contacts", false, nil)
fmt.Printf("full page: html=%v rows=%s count=%v\n", strings.Contains(body, "<html"), list(body), strings.Contains(body, "(12 shown)"))
pageBytes := len(body)
_, body = do("GET", "/contacts?q=lima", true, nil)
fmt.Printf("HX search lima: html=%v table=%v rows=%s, %d bytes against %d for the full page\n", strings.Contains(body, "<html"), strings.Contains(body, `<table id="results">`), list(body), len(body), pageBytes)
_, body = do("GET", "/contacts?page=2", true, nil)
fmt.Printf("HX page 2: table=%v rows=%s loadmore=%v\n", strings.Contains(body, "<table"), list(body), strings.Contains(body, "page=3"))
_, body = do("GET", "/contacts?page=3", true, nil)
fmt.Printf("HX page 3: rows=%s loadmore=%v\n", list(body), strings.Contains(body, "revealed"))
_, body = do("GET", "/contacts?q=zzz", true, nil)
fmt.Printf("HX no match: %q\n", regexp.MustCompile(`<tr><td>([^<]*)</td></tr>`).FindStringSubmatch(body)[1])
_, body = do("GET", "/contacts?page=1", false, nil)
fmt.Printf("hostile name escaped: %v\n", strings.Contains(body, "Ada <b>Lovelace</b>") && !strings.Contains(body, "<b>Lovelace"))
res, body := do("GET", "/contacts/3/edit", true, nil)
fmt.Printf("edit form: %d inputs=%d\n", res.StatusCode, strings.Count(body, "<input"))
res, body = do("PUT", "/contacts/3", true, url.Values{"name": {""}, "email": {"nope"}, "city": {"Oslo"}})
fmt.Printf("invalid save: %d errors=%d form-again=%v\n", res.StatusCode, strings.Count(body, `role="alert"`), strings.Contains(body, "<form"))
res, body = do("PUT", "/contacts/3", true, url.Values{"name": {"Ada Lovelace"}, "email": {"[email protected]"}, "city": {"London"}})
fmt.Printf("valid save: %d row=%v London=%v form=%v\n", res.StatusCode, rows(body) == 1, strings.Contains(body, "London"), strings.Contains(body, "<form"))
res, body = do("DELETE", "/contacts/4", true, nil)
fmt.Printf("delete: %d body=%s\n", res.StatusCode, body)
res, _ = do("DELETE", "/contacts/4", true, nil)
fmt.Printf("delete again: %d\n", res.StatusCode)
res, _ = do("GET", "/contacts", false, nil)
fmt.Printf("Vary header: %q\n", res.Header.Get("Vary"))
}go get github.com/a-h/[email protected] >/dev/null 2>&1
go get -tool github.com/a-h/templ/cmd/[email protected] >/dev/null 2>&1
go tool templ generate >/dev/null 2>&1
go run .full page: html=true rows=1,2,3,4,5 count=true
HX search lima: html=false table=true rows=3,7,11, 977 bytes against 2041 for the full page
HX page 2: table=false rows=6,7,8,9,10 loadmore=true
HX page 3: rows=11,12 loadmore=false
HX no match: "No contacts match \"zzz\"."
hostile name escaped: true
edit form: 200 inputs=3
invalid save: 422 errors=2 form-again=true
valid save: 200 row=true London=true form=false
delete: 200 body=<span id="count" hx-swap-oob="true">(11 shown)</span>
delete again: 404
Vary header: "HX-Request"Read the output as a list of guarantees. The full page lists contacts 1 to 5, since the page size is five, and shows (12 shown). The HTMX search for lima returns only the table, with contacts 3, 7 and 11, and no <html>; the program also prints the size, 977 bytes against 2,041 for the full first page, so each keystroke-triggered request moves less than half the bytes of a navigation, and the saving grows with the weight of the layout. Page two as an HTMX request is rows only, 6 to 10, with a load-more trigger to page three; page three has rows 11 and 12 and no trigger, so scrolling stops. A search with no matches renders a message row. The contact whose name contains <b> is escaped as text, so the stored data cannot become markup.
The edit form has three inputs. The invalid save is a 422 that contains two error messages and the form again. The valid save returns one row with the new city and no form. The delete answers with the out-of-band count showing (11 shown), a second delete of the same contact is a 404, and every response carries Vary: HX-Request.
Tests for the contracts
package main
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"testing"
)
func call(t *testing.T, h http.Handler, method, path string, hx bool, form url.Values) *httptest.ResponseRecorder {
t.Helper()
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req := httptest.NewRequest(method, path, body)
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if hx {
req.Header.Set("HX-Request", "true")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestSearchFragmentIsExactlyTheTableOfTheFullPage(t *testing.T) {
h := NewApp().Routes()
full := call(t, h, "GET", "/contacts?q=lima", false, nil).Body.String()
frag := call(t, h, "GET", "/contacts?q=lima", true, nil).Body.String()
start := strings.Index(full, `<table id="results">`)
end := strings.Index(full, "</table>") + len("</table>")
if start < 0 || full[start:end] != frag {
t.Fatalf("the fragment drifted from the page.\nfragment: %s\npage part: %s", frag, full[max(start, 0):end])
}
}
func TestLoadMoreVisitsEveryContactOnce(t *testing.T) {
h := NewApp().Routes()
idRe := regexp.MustCompile(`<tr id="contact-(\d+)"`)
var seen []int
path := "/contacts?page=1"
for i := 0; i < 10; i++ { // a bound, so a bug cannot loop forever
body := call(t, h, "GET", path, i > 0, nil).Body.String()
for _, m := range idRe.FindAllStringSubmatch(body, -1) {
n, _ := strconv.Atoi(m[1])
seen = append(seen, n)
}
next := regexp.MustCompile(`hx-get="(/contacts\?page=\d+[^"]*)"`).FindStringSubmatch(body)
if next == nil {
break
}
path = strings.ReplaceAll(next[1], "&", "&")
}
sort.Ints(seen)
if len(seen) != 12 {
t.Fatalf("saw %v, want each of 1 to 12 exactly once", seen)
}
for i, n := range seen {
if n != i+1 {
t.Fatalf("saw %v, want each of 1 to 12 exactly once", seen)
}
}
}
func TestEditValidationKeepsTheFormAndTheValues(t *testing.T) {
h := NewApp().Routes()
rec := call(t, h, "PUT", "/contacts/2", true, url.Values{"name": {" "}, "email": {"bad"}, "city": {"Oslo"}})
body := rec.Body.String()
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(body, "Name is required") || !strings.Contains(body, `value="Oslo"`) {
t.Fatalf("status %d, body %s", rec.Code, body)
}
}
func TestDeleteReturnsAnOutOfBandCount(t *testing.T) {
h := NewApp().Routes()
body := call(t, h, "DELETE", "/contacts/1", true, nil).Body.String()
if !strings.Contains(body, `hx-swap-oob="true"`) || !strings.Contains(body, "(11 shown)") {
t.Fatalf("body %s", body)
}
}go get github.com/a-h/[email protected] >/dev/null 2>&1
go get -tool github.com/a-h/templ/cmd/[email protected] >/dev/null 2>&1
go tool templ generate >/dev/null 2>&1
go test -race -count=1 -v ./...=== RUN TestSearchFragmentIsExactlyTheTableOfTheFullPage
--- PASS: TestSearchFragmentIsExactlyTheTableOfTheFullPage (0.00s)
=== RUN TestLoadMoreVisitsEveryContactOnce
--- PASS: TestLoadMoreVisitsEveryContactOnce (0.00s)
=== RUN TestEditValidationKeepsTheFormAndTheValues
--- PASS: TestEditValidationKeepsTheFormAndTheValues (0.00s)
=== RUN TestDeleteReturnsAnOutOfBandCount
--- PASS: TestDeleteReturnsAnOutOfBandCount (0.00s)
PASS
ok example.test/lab 1.297sThe first test is the one that justifies fragments. It requests the same search twice, as a full page and as an HTMX request, and checks that the HTMX body is byte-identical to the results table inside the full page. Any future edit that makes the two paths diverge, such as a developer adding a second, special template for the fragment, fails here. The second test follows the load-more links the way a browser would, until there are none, and asserts that every contact appears exactly once, which catches off-by-one mistakes in the paging. The third checks that a failed validation keeps what the user typed. The fourth pins the out-of-band count.
Fragments in more detail
A fragment is identified by any comparable value, and a component can hold several, so one page can expose results, filters and summary independently. Fragments nest, and templ.WithFragments("a", "b") renders more than one in sequence, which is how a single response can update two places without out-of-band tricks. Ids are just strings and typos fail quietly (an unknown id renders nothing), so keep them as constants and test each one, as the first test above does for results. Use fragments for the regions an interaction will re-request, and plain components for anything reused across pages; the two ideas combine, since a fragment can contain component calls, as Rows is inside the table here.
Paging that survives changing data
The page-number scheme in this demo is easy to follow and has a known weakness. If a contact is added or deleted while someone scrolls, offset-based pages shift and the reader sees a row twice or misses one. With a real database, switch to keyset paging, where the load-more URL carries the last seen key (/contacts?after=7) and the query asks for id > 7 ORDER BY id LIMIT 5; the template is unchanged except for the URL builder, and the behavior stays stable under writes. The trade-offs between offset, cursor and keyset paging are covered in pagination for APIs.
Endless scrolling has an accessibility cost as well: a footer that keeps moving away is unreachable by keyboard, and screen reader users get no signal that content was appended. A visible "Load more" button as the trigger, with hx-trigger="click", avoids both problems and is easier to test. Use revealed only where the list is long and the benefit is real, and announce additions with an aria-live region.
What the browser needs to be told
There are two things the server cannot do alone. First, HTMX by default does not swap responses with 4xx or 5xx status codes into the page. The 422 from the edit form would be dropped and the user would see nothing. HTMX 2 lets you configure which statuses swap through its responseHandling setting; allow 422 there, per the HTMX documentation. Many projects instead return 200 with the form for validation errors, which needs no configuration and loses the semantic status, so decide once and keep it consistent.
Second, progressive enhancement is not automatic. The search input in this demo is not inside a form, so with JavaScript disabled, pressing Enter does nothing. Wrapping it in <form action="/contacts" method="get"> gives a working no-JavaScript path to the same handler, because the handler already returns a full page for a plain request. The pages should be usable without the script, even if a few conveniences need it.
Other details are the same as for any HTMX application. Protect PUT and DELETE against CSRF, as in the forms article. Add hx-confirm where an action cannot be undone (the delete button has it). Mark regions that update themselves with aria-live and keep focus in a sensible place after a swap; when the edit form replaces a row, the first input should receive focus, which HTMX does for elements with the autofocus attribute. And keep the script self-hosted, pinned to a version.
Choosing which interactions to enhance
Not every control deserves an HTMX attribute. The pattern I follow is to build every screen as a plain server rendered page first, with links and forms that work on their own, and then add attributes where a full reload hurts: search that updates as you type, a row that edits in place, a list that grows. Each enhancement should degrade to the plain behavior when the script is missing or fails to load, which is also what makes the screens testable with nothing but httptest. When an interaction needs client state that outlives a request, such as a drag-and-drop board or a canvas, HTMX is the wrong tool and a small amount of purpose-written JavaScript for that one widget is the honest answer.
Deploying and operating it
Nothing about this application is special at runtime: it is an http.Handler built from plain functions. Serve the HTMX script from your own origin, set Cache-Control on it as for any static file (cache headers), and put the usual middleware around the mux: request IDs, logging, recovery and rate limiting for the search endpoint, which is the one a bot will hit hardest. The search runs a substring scan over every contact, which is fine for a few thousand rows in memory and wrong for a database table of millions; at that size the filter becomes a query, with an index or a full-text search, as in adding search to a Go website.
Log the HTMX requests separately in your metrics. The ratio of fragment requests to full page loads tells you how interactive the application really is, and a sudden rise in 422 responses on the edit endpoint tells you the form's validation message is not helping people. Both are visible for free because the status code and the HX-Request header are already on every request.
What templ added over plain templates
Compared with building the same screen in html/template, three things stand out. The props are typed: ListView is a struct and Row takes a Contact, so renaming a field breaks the build instead of a page. The fragments live inside the page, which removes the "which template holds the table" question. And the components compose in Go: Rows calls Row in a loop with a different argument for the last one, which in a string template needs awkward index arithmetic. The cost is the generation step, covered in the tutorial and live reload. Fragment rendering is documented in the templ guide, and the full API is on pkg.go.dev.


