Build a Server Rendered Todo App With Templ and HTMX
Build a todo app in Go with templ, HTMX and SQLite where every action works as a plain form post and upgrades to fragment updates, with tests for both paths.

The todo list is the traditional first application for every web framework, which makes it a useful baseline for comparing approaches. Built with HTMX it also becomes a good test of a particular claim: that a server rendered application can feel interactive without giving up the robustness of plain HTML. The claim is easy to make and easy to break, because the moment a button is a div with a click handler, the page stops working without JavaScript, stops being testable with curl, and starts needing a client-side model of the server's data.
Tested with Go 1.25.6, templ v0.3.1001 and modernc.org/sqlite v1.50.0, macOS on Apple Silicon (arm64)
This project builds the app so that the claim holds. Every control is an ordinary form. HTMX attributes upgrade them, and the server answers whichever way the request asked. It uses SQLite for storage, templ for the views, and tests both modes. It continues the contacts directory, which covered fragments, live search and endless scrolling, so here the focus is the dual-mode handler design. It is related to the earlier Go todo app and the HTMX dashboard, and belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6 with templ v0.3.1001 and modernc.org/sqlite v1.50.0.
The store
package main
import (
"context"
"database/sql"
"errors"
"time"
_ "modernc.org/sqlite"
)
var ErrNotFound = errors.New("todo not found")
type Todo struct {
ID int64
Title string
Done bool
}
type Counts struct{ Active, Completed int }
type Store struct{ db *sql.DB }
func OpenStore(dsn string) (*Store, error) {
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
)`)
if err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) Add(ctx context.Context, title string) (Todo, error) {
res, err := s.db.ExecContext(ctx, `INSERT INTO todos (title, created_at) VALUES (?, ?)`, title, time.Now().Unix())
if err != nil {
return Todo{}, err
}
id, _ := res.LastInsertId()
return Todo{ID: id, Title: title}, nil
}
// List returns todos newest first; filter is "", "active" or "completed".
func (s *Store) List(ctx context.Context, filter string) ([]Todo, error) {
q := `SELECT id, title, done FROM todos`
switch filter {
case "active":
q += ` WHERE done = 0`
case "completed":
q += ` WHERE done = 1`
}
rows, err := s.db.QueryContext(ctx, q+` ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Todo
for rows.Next() {
var t Todo
var done int
if err := rows.Scan(&t.ID, &t.Title, &done); err != nil {
return nil, err
}
t.Done = done == 1
out = append(out, t)
}
return out, rows.Err()
}
func (s *Store) Get(ctx context.Context, id int64) (Todo, error) {
var t Todo
var done int
err := s.db.QueryRowContext(ctx, `SELECT id, title, done FROM todos WHERE id = ?`, id).Scan(&t.ID, &t.Title, &done)
if errors.Is(err, sql.ErrNoRows) {
return Todo{}, ErrNotFound
}
t.Done = done == 1
return t, err
}
func (s *Store) Counts(ctx context.Context) (Counts, error) {
var c Counts
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(done = 0), 0), COALESCE(SUM(done = 1), 0) FROM todos`).Scan(&c.Active, &c.Completed)
return c, err
}
// Toggle flips the done flag and returns the new state in the same statement.
func (s *Store) Toggle(ctx context.Context, id int64) (Todo, error) {
var t Todo
var done int
err := s.db.QueryRowContext(ctx, `UPDATE todos SET done = 1 - done WHERE id = ? RETURNING id, title, done`, id).Scan(&t.ID, &t.Title, &done)
if errors.Is(err, sql.ErrNoRows) {
return Todo{}, ErrNotFound
}
t.Done = done == 1
return t, err
}
func (s *Store) Rename(ctx context.Context, id int64, title string) (Todo, error) {
var t Todo
var done int
err := s.db.QueryRowContext(ctx, `UPDATE todos SET title = ? WHERE id = ? RETURNING id, title, done`, title, id).Scan(&t.ID, &t.Title, &done)
if errors.Is(err, sql.ErrNoRows) {
return Todo{}, ErrNotFound
}
t.Done = done == 1
return t, err
}
func (s *Store) Delete(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM todos WHERE id = ?`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) ClearCompleted(ctx context.Context) (int64, error) {
res, err := s.db.ExecContext(ctx, `DELETE FROM todos WHERE done = 1`)
if err != nil {
return 0, err
}
return res.RowsAffected()
}The store is a thin layer of SQL. The statements to notice are Toggle and Rename, both written as UPDATE ... RETURNING: the row changes and the new state comes back in one statement, so there is no window between "flip the flag" and "read it back" in which another request can interleave. Counts does its two sums in one query using SUM(done = 0), which works because SQLite evaluates a comparison to 0 or 1; COALESCE keeps an empty table at zero instead of NULL. The setup follows the URL shortener: one connection, RETURNING, in-memory for tests.
The views
package main
import "fmt"
type View struct {
Todos []Todo
Counts Counts
Filter string
Error string
}
templ Layout(title string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ title }</title>
<script src="/static/htmx.min.js" defer></script>
</head>
<body>
<main>
{ children... }
</main>
</body>
</html>
}
templ Page(v View) {
@Layout("Todos") {
<h1>Todos</h1>
@AddForm(v.Error)
@List(v.Todos)
@Footer(v.Counts, v.Filter, false)
}
}
// AddForm works without JavaScript (a normal POST and redirect); hx-post upgrades it to an in-place update.
templ AddForm(errMsg string) {
<form
id="add"
method="post"
action="/todos"
hx-post="/todos"
hx-target="#todos"
hx-swap="afterbegin"
hx-on::after-request="if (event.detail.successful) this.reset()"
>
<label for="title">What needs doing?</label>
<input
id="title"
name="title"
maxlength="200"
autofocus
required
if errMsg != "" {
aria-invalid="true"
aria-describedby="title-error"
}
/>
<button>Add</button>
if errMsg != "" {
<p id="title-error" class="error" role="alert">{ errMsg }</p>
}
</form>
}
templ List(todos []Todo) {
<ul id="todos">
for _, t := range todos {
@Item(t)
}
</ul>
}
templ Item(t Todo) {
<li id={ fmt.Sprintf("todo-%d", t.ID) } class={ templ.KV("done", t.Done) }>
<form method="post" action={ templ.URL(fmt.Sprintf("/todos/%d/toggle", t.ID)) } hx-post={ fmt.Sprintf("/todos/%d/toggle", t.ID) } hx-target="closest li" hx-swap="outerHTML">
<button aria-pressed={ fmt.Sprint(t.Done) } aria-label={ "Mark " + t.Title + " as done" }>
if t.Done {
[x]
} else {
[ ]
}
</button>
</form>
<span hx-get={ fmt.Sprintf("/todos/%d/edit", t.ID) } hx-trigger="dblclick" hx-target="closest li" hx-swap="outerHTML">{ t.Title }</span>
<form method="post" action={ templ.URL(fmt.Sprintf("/todos/%d/delete", t.ID)) } hx-post={ fmt.Sprintf("/todos/%d/delete", t.ID) } hx-target="closest li" hx-swap="outerHTML">
<button aria-label={ "Delete " + t.Title }>Delete</button>
</form>
</li>
}
templ EditItem(t Todo) {
<li id={ fmt.Sprintf("todo-%d", t.ID) }>
<form method="post" action={ templ.URL(fmt.Sprintf("/todos/%d/rename", t.ID)) } hx-post={ fmt.Sprintf("/todos/%d/rename", t.ID) } hx-target="closest li" hx-swap="outerHTML">
<input name="title" value={ t.Title } maxlength="200" required autofocus/>
<button>Save</button>
</form>
</li>
}
// Footer shows the counts and the filters. Sent out of band after an action, it keeps the numbers right.
templ Footer(c Counts, filter string, oob bool) {
<footer
id="footer"
if oob {
hx-swap-oob="true"
}
>
<span>{ fmt.Sprint(c.Active) } left</span>
<nav>
for _, f := range []string{"", "active", "completed"} {
<a
href={ templ.URL("/?f=" + f) }
if f == filter {
aria-current="page"
}
>
if f == "" {
All
} else {
{ f }
}
</a>
}
</nav>
if c.Completed > 0 {
<form method="post" action="/todos/clear-completed" hx-post="/todos/clear-completed" hx-target="#todos" hx-swap="outerHTML">
<button>Clear completed ({ fmt.Sprint(c.Completed) })</button>
</form>
}
</footer>
}The structure of the components encodes the design.
Every action is a form. The checkbox is a button inside a form that posts to /todos/N/toggle. The delete button is a form that posts to /todos/N/delete. The add form posts to /todos. Without JavaScript the browser submits them as ordinary POSTs. The hx-post attributes on the same forms tell HTMX to intercept the submit, send it in the background and swap the answer in. The accessible names (aria-label, aria-pressed) make the buttons usable for screen readers whichever way they are driven. A form per button is more markup than a click handler, and it is what buys the fallback.
Double click to edit is the one enhancement with no fallback. The title span has hx-trigger="dblclick", which loads the edit form in place. Without JavaScript there is no editing in this version; a no-JS user could be offered a link to a separate edit page. It is worth being explicit about which features degrade and which disappear.
The footer is rendered with an oob flag. On a normal page load it is part of the layout. After an action it is sent again with hx-swap-oob="true", so the counts and the filter links update from the same response that changes the item.
The handlers
package main
import (
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"unicode/utf8"
"github.com/a-h/templ"
)
type App struct{ store *Store }
func (a *App) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", a.index)
mux.HandleFunc("POST /todos", a.add)
mux.HandleFunc("POST /todos/clear-completed", a.clearCompleted)
mux.HandleFunc("GET /todos/{id}/edit", a.edit)
mux.HandleFunc("POST /todos/{id}/toggle", a.toggle)
mux.HandleFunc("POST /todos/{id}/rename", a.rename)
mux.HandleFunc("POST /todos/{id}/delete", a.remove)
return vary(http.NewCrossOriginProtection().Handler(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 isHX(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" }
// filterFrom reads the list filter from the page the HTMX request came from.
func filterFrom(r *http.Request) string {
if u, err := url.Parse(r.Header.Get("HX-Current-URL")); err == nil {
if f := u.Query().Get("f"); f == "active" || f == "completed" {
return f
}
}
return ""
}
func (a *App) view(r *http.Request, filter, errMsg string) (View, error) {
todos, err := a.store.List(r.Context(), filter)
if err != nil {
return View{}, err
}
counts, err := a.store.Counts(r.Context())
return View{Todos: todos, Counts: counts, Filter: filter, Error: errMsg}, err
}
func fail(w http.ResponseWriter, err error) {
if errors.Is(err, ErrNotFound) {
http.Error(w, "no such todo", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
}
func (a *App) index(w http.ResponseWriter, r *http.Request) {
f := r.URL.Query().Get("f")
if f != "active" && f != "completed" {
f = ""
}
v, err := a.view(r, f, "")
if err != nil {
fail(w, err)
return
}
templ.Handler(Page(v)).ServeHTTP(w, r)
}
// respond answers an action: HTMX gets fragments, a plain browser gets a redirect back to the list.
func (a *App) respond(w http.ResponseWriter, r *http.Request, main templ.Component) {
if !isHX(r) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
counts, err := a.store.Counts(r.Context())
if err != nil {
fail(w, err)
return
}
comps := []templ.Component{Footer(counts, filterFrom(r), true)}
if main != nil {
comps = append([]templ.Component{main}, comps...)
}
templ.Handler(templ.Join(comps...)).ServeHTTP(w, r)
}
func (a *App) add(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<14)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
title := strings.TrimSpace(r.PostForm.Get("title"))
if title == "" || utf8.RuneCountInString(title) > 200 {
const msg = "Enter a title of 1 to 200 characters"
if isHX(r) {
w.Header().Set("HX-Retarget", "#add")
w.Header().Set("HX-Reswap", "outerHTML")
templ.Handler(AddForm(msg), templ.WithStatus(http.StatusUnprocessableEntity)).ServeHTTP(w, r)
return
}
v, err := a.view(r, "", msg)
if err != nil {
fail(w, err)
return
}
templ.Handler(Page(v), templ.WithStatus(http.StatusUnprocessableEntity)).ServeHTTP(w, r)
return
}
t, err := a.store.Add(r.Context(), title)
if err != nil {
fail(w, err)
return
}
a.respond(w, r, Item(t))
}
func id(r *http.Request) int64 { n, _ := strconv.ParseInt(r.PathValue("id"), 10, 64); return n }
func (a *App) toggle(w http.ResponseWriter, r *http.Request) {
t, err := a.store.Toggle(r.Context(), id(r))
if err != nil {
fail(w, err)
return
}
a.respond(w, r, Item(t))
}
func (a *App) edit(w http.ResponseWriter, r *http.Request) {
t, err := a.store.Get(r.Context(), id(r))
if err != nil {
fail(w, err)
return
}
templ.Handler(EditItem(t)).ServeHTTP(w, r)
}
func (a *App) rename(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
title := strings.TrimSpace(r.PostForm.Get("title"))
if title == "" || utf8.RuneCountInString(title) > 200 {
http.Error(w, "title must be 1 to 200 characters", http.StatusUnprocessableEntity)
return
}
t, err := a.store.Rename(r.Context(), id(r), title)
if err != nil {
fail(w, err)
return
}
if !isHX(r) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
templ.Handler(Item(t)).ServeHTTP(w, r)
}
func (a *App) remove(w http.ResponseWriter, r *http.Request) {
if err := a.store.Delete(r.Context(), id(r)); err != nil {
fail(w, err)
return
}
a.respond(w, r, nil) // no main content: the targeted row is replaced by nothing
}
func (a *App) clearCompleted(w http.ResponseWriter, r *http.Request) {
if _, err := a.store.ClearCompleted(r.Context()); err != nil {
fail(w, err)
return
}
todos, err := a.store.List(r.Context(), filterFrom(r))
if err != nil {
fail(w, err)
return
}
a.respond(w, r, List(todos))
}The handler design is in two helpers. respond is the dual-mode switch: for an HTMX request it renders the item (or nothing, or the list) joined with the out-of-band footer; for any other request it redirects with 303 See Other to the list. The redirect is the post-redirect-get pattern, so a refresh after a plain POST does not resubmit it. filterFrom reads the HX-Current-URL header that HTMX sends, to know which filter the page is showing, so the footer response keeps the right link highlighted.
Validation shows both paths. On the plain path, an empty title re-renders the whole page with status 422 and the message by the field. On the HTMX path, the response is just the form, with HX-Retarget: #add and HX-Reswap: outerHTML headers that redirect the swap to the form itself, instead of the list the request was aimed at. Because HTMX does not swap 4xx responses unless configured, allow 422 as described in the contacts article, or return 200 for validation errors.
Routes wraps the mux in http.NewCrossOriginProtection().Handler(...), the Go 1.25 check described in the forms article. Every action is a POST, so every action is protected, with no token in any form.
Both modes, end to end
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
)
func main() {
store, err := OpenStore("file::memory:")
if err != nil {
panic(err)
}
defer store.Close()
srv := httptest.NewServer((&App{store: store}).Routes())
defer srv.Close()
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
do := func(method, path string, hx bool, form url.Values, hdr ...string) (*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")
}
for i := 0; i+1 < len(hdr); i += 2 {
req.Header.Set(hdr[i], hdr[i+1])
}
res, err := noRedirect.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
return res, string(b)
}
titles := regexp.MustCompile(`<span hx-get="[^"]*" hx-trigger="dblclick"[^>]*>([^<]*)</span>`)
list := func(body string) []string {
var out []string
for _, m := range titles.FindAllStringSubmatch(body, -1) {
out = append(out, m[1])
}
return out
}
left := regexp.MustCompile(`(\d+) left`)
fmt.Println("-- without JavaScript: every action is a form POST and a redirect")
res, _ := do("POST", "/todos", false, url.Values{"title": {"write the article"}})
fmt.Println("add:", res.StatusCode, res.Header.Get("Location"))
res, _ = do("POST", "/todos", false, url.Values{"title": {"<b>ship it</b>"}})
_, page := do("GET", "/", false, nil)
fmt.Println("list:", list(page), "| escaped:", strings.Contains(page, "<b>ship it"), "|", left.FindString(page))
res, page = do("POST", "/todos", false, url.Values{"title": {" "}})
fmt.Println("empty title:", res.StatusCode, "| form shows the error:", strings.Contains(page, "title-error"))
res, _ = do("POST", "/todos/1/toggle", false, nil)
_, page = do("GET", "/?f=completed", false, nil)
fmt.Println("toggle:", res.StatusCode, "| completed filter shows:", list(page))
fmt.Println("-- with HTMX: fragments instead of redirects")
res, body := do("POST", "/todos", true, url.Values{"title": {"call the printer"}})
fmt.Printf("add: %d item=%v footer-oob=%v page=%v\n", res.StatusCode, strings.Contains(body, `id="todo-3"`), strings.Contains(body, `hx-swap-oob="true"`), strings.Contains(body, "<html"))
res, body = do("POST", "/todos", true, url.Values{"title": {""}})
fmt.Println("empty title:", res.StatusCode, "| retarget:", res.Header.Get("HX-Retarget"), "| reswap:", res.Header.Get("HX-Reswap"), "| only the form:", strings.HasPrefix(body, "<form"))
res, body = do("POST", "/todos/3/toggle", true, nil, "HX-Current-URL", srv.URL+"/?f=active")
fmt.Println("toggle:", res.StatusCode, "| done class:", strings.Contains(body, `class="done"`), "|", left.FindString(body), "| active filter kept:", strings.Contains(body, `aria-current="page">active`))
res, body = do("GET", "/todos/3/edit", true, nil)
fmt.Println("edit form:", res.StatusCode, strings.Contains(body, `value="call the printer"`))
res, body = do("POST", "/todos/3/rename", true, url.Values{"title": {"call the printer back"}})
fmt.Println("rename:", res.StatusCode, list(body))
res, body = do("POST", "/todos/clear-completed", true, nil)
fmt.Println("clear completed:", res.StatusCode, list(body), "|", left.FindString(body))
res, _ = do("POST", "/todos/99/delete", true, nil)
fmt.Println("delete a missing todo:", res.StatusCode)
res, _ = do("POST", "/todos", false, url.Values{"title": {"forged"}}, "Sec-Fetch-Site", "cross-site")
fmt.Println("cross-site POST:", res.StatusCode)
}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 .-- without JavaScript: every action is a form POST and a redirect
add: 303 /
list: [<b>ship it</b> write the article] | escaped: true | 2 left
empty title: 422 | form shows the error: true
toggle: 303 | completed filter shows: [write the article]
-- with HTMX: fragments instead of redirects
add: 200 item=true footer-oob=true page=false
empty title: 422 | retarget: #add | reswap: outerHTML | only the form: true
toggle: 200 | done class: true | 1 left | active filter kept: true
edit form: 200 true
rename: 200 [call the printer back]
clear completed: 200 [<b>ship it</b>] | 1 left
delete a missing todo: 404
cross-site POST: 403The first half of the output is the app with JavaScript off. Adding a todo is a 303 back to /. The title containing markup is listed as <b>ship it</b>, escaped, and the list shows newest first with two left. An empty title is a 422 and the page itself carries the error message beside the field. Toggling returns a redirect, and the completed filter then shows only the finished item.
The second half is the same app as HTMX drives it. Adding returns a fragment containing the new item and an out-of-band footer, and not a page. The empty title gets the retargeting headers and a response that begins with <form. Toggling returns the item with the done class, the new count (1 left), and the footer with the active link still marked current, because HX-Current-URL told the server which filter was showing. The edit form comes back populated, renaming returns the updated item, clearing completed returns the list without the finished items and a fresh footer, deleting a missing todo is a 404, and a cross-site POST is rejected with 403.
Tests for the store and the escaping
package main
import (
"context"
"errors"
"strings"
"sync"
"testing"
)
func newStore(t *testing.T) *Store {
t.Helper()
s, err := OpenStore("file::memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestToggleTwiceRestoresTheTodo(t *testing.T) {
s, ctx := newStore(t), context.Background()
todo, _ := s.Add(ctx, "water the plants")
on, _ := s.Toggle(ctx, todo.ID)
off, _ := s.Toggle(ctx, todo.ID)
if !on.Done || off.Done {
t.Fatalf("toggle results: %v then %v", on.Done, off.Done)
}
}
func TestCountsAndClearCompleted(t *testing.T) {
s, ctx := newStore(t), context.Background()
for _, title := range []string{"a", "b", "c", "d"} {
s.Add(ctx, title)
}
s.Toggle(ctx, 1)
s.Toggle(ctx, 3)
if c, _ := s.Counts(ctx); c.Active != 2 || c.Completed != 2 {
t.Fatalf("counts = %+v", c)
}
if n, _ := s.ClearCompleted(ctx); n != 2 {
t.Fatalf("cleared %d, want 2", n)
}
left, _ := s.List(ctx, "")
if len(left) != 2 || left[0].Title != "d" || left[1].Title != "b" {
t.Fatalf("remaining %+v, want d then b (newest first)", left)
}
}
func TestMissingTodosAreNotFound(t *testing.T) {
s, ctx := newStore(t), context.Background()
if _, err := s.Toggle(ctx, 42); !errors.Is(err, ErrNotFound) {
t.Errorf("toggle: %v", err)
}
if err := s.Delete(ctx, 42); !errors.Is(err, ErrNotFound) {
t.Errorf("delete: %v", err)
}
if _, err := s.Get(ctx, 42); !errors.Is(err, ErrNotFound) {
t.Errorf("get: %v", err)
}
}
func TestConcurrentTogglesAreNotLost(t *testing.T) {
s, ctx := newStore(t), context.Background()
todo, _ := s.Add(ctx, "contended")
var wg sync.WaitGroup
for i := 0; i < 21; i++ { // an odd number: the final state must be done
wg.Add(1)
go func() { defer wg.Done(); s.Toggle(ctx, todo.ID) }()
}
wg.Wait()
if got, _ := s.Get(ctx, todo.ID); !got.Done {
t.Fatal("21 toggles of one todo must leave it done; an update was lost")
}
}
func TestTitlesAreEscapedByTheComponents(t *testing.T) {
var sb strings.Builder
if err := Item(Todo{ID: 1, Title: `"><img src=x onerror=alert(1)>`}).Render(context.Background(), &sb); err != nil {
t.Fatal(err)
}
if strings.Contains(sb.String(), "<img") {
t.Fatalf("title became markup: %s", sb.String())
}
}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 ./... 2>&1 | grep -E "^(--- |ok|FAIL|PASS)"--- PASS: TestToggleTwiceRestoresTheTodo (0.01s)
--- PASS: TestCountsAndClearCompleted (0.01s)
--- PASS: TestMissingTodosAreNotFound (0.00s)
--- PASS: TestConcurrentTogglesAreNotLost (0.01s)
--- PASS: TestTitlesAreEscapedByTheComponents (0.00s)
PASS
ok example.test/lab 1.422sThe tests pin the properties the design depends on. Toggling twice must restore the todo; counts and clearing must agree; missing ids must be ErrNotFound (which the handlers turn into 404s); and 21 concurrent toggles of one todo must leave it done, which holds only because the flip happens inside one SQL statement. A read-modify-write in Go would lose updates under that load. The last test renders an item with a hostile title and checks it stayed text.
Double submits and slow networks
Two things go wrong on real connections. A person on a slow network presses Add twice because nothing seems to happen, and the server creates two todos. The first defence is on the client: hx-disabled-elt="find button" on the form disables its button while the request is in flight, and the plain-form fallback relies on the browser's own behavior of ignoring a second submit while the page is loading. The second defence is on the server, for the cases the client cannot prevent (two tabs, a retry by a flaky proxy): make the create request idempotent with a token in a hidden field, generated when the form is rendered and remembered when used, so a repeated submission is recognised and answered with the original result. The same idea is behind idempotency keys in APIs. For a todo list a duplicate is an annoyance; for an order form it is a double charge, and the pattern is the same.
The second problem is stale pages. A todo toggled in one tab is stale in another. The cheap fix is that every action returns the current truth for what it touched (the item and the counts), so the next click corrects the page, and a periodic hx-trigger="every 30s" refresh of the list covers the rest. For true multi-user live updates, add server sent events and the HTMX SSE extension; do that only when the application needs it.
The progressive enhancement checklist
Before calling an HTMX screen done, I run through five questions. Does every link and form work with the script blocked? Open the page with JavaScript disabled and click everything. Does each URL return a complete page when opened directly or refreshed, even if it was reached through a fragment swap? The hx-push-url attribute and the filter links in this app make that true. Is Vary: HX-Request on every response that depends on the header, so no cache serves a fragment as a page? Does focus end up somewhere sensible after a swap? After adding a todo the input keeps focus because the form is not replaced; after deleting an item, focus should move to the next item or the list, which needs a small hx-on handler or the HX-Trigger event pattern. And are changes announced to assistive technology, with a live region or at least a meaningful focus move?
Each of those is cheap to fix when the app is small and expensive to retrofit. The first two are also the tests: the plain half of the program above is a complete regression suite for "works without JavaScript".
Accessibility notes specific to this app
The toggle button uses aria-pressed and an aria-label that names the todo, so a screen reader announces "Mark write the article as done, toggle button, not pressed". The bracketed [x] and [ ] labels are visual stand-ins; a real design would draw a checkbox and keep the same ARIA. Error text is linked to the input with aria-describedby and marked role="alert". The edit action is available only through double click in this version, which is not discoverable and not keyboard accessible; a visible "Edit" button with the same hx-get fixes both and should be added before shipping.
Packaging and deploying it
The deployment story is as short as the application. go build produces one binary that embeds the compiled templates; the HTMX script and any CSS go in an embedded static directory served with http.FileServerFS, as in embedded static files. The database is a file next to the binary (set the DSN to a path, enable WAL mode, and back it up with VACUUM INTO or a file copy while the app is idle). Put it behind TLS from a reverse proxy or autocert, add rate limiting on the POST routes, and set the server timeouts from the cheat sheet. A container image built FROM scratch with the single binary is a few megabytes. For a team tool or a side project this is the entire infrastructure, and it is a good argument for the approach: the interactivity comes from about a hundred lines of attributes, and the operations burden comes from one process and one file.
How big is each response?
The point of fragments is that an action moves a few hundred bytes instead of a page, and it is easy to check rather than assume. A toggle response is one list item plus the footer. Compare that with the full page in your browser's network panel, or add len(body) prints to the program as the contacts article does. The saving is small for a toy page and grows with the weight of the layout, the number of items, and the size of any inlined styles, which is why fragment endpoints matter most on pages that carry a lot of chrome.
Decisions worth knowing about
Why not rely on JavaScript? Because the fallback is the test strategy as well as the accessibility strategy. Every behavior can be driven with net/http and asserted without a browser, as the program above does. Anything that only works through JavaScript needs a browser test, which is slower and flakier, so keep those few (the double click to edit) small.
Where does the filter live? In the URL (/?f=active), not in session state or hidden fields, so links are shareable, the back button works and the server stays stateless. HTMX supplies the current URL on every request in HX-Current-URL, which saves threading the filter through each form.
What about concurrent users? This app has one shared list, which is fine for a demo. A real one scopes todos to a user, adds that user to every query, and checks ownership on each action; the access-control rules are in the RBAC middleware article, and the stored data would carry a user_id.
What is missing? Persistence on disk (a file DSN instead of :memory:), a migration tool as the schema grows, optimistic concurrency for editing the same title from two tabs, and a limit on the number of todos per user. None changes the shape of the handlers. The libraries and the syntax are documented in the templ guide and the HTMX documentation.


