If you want interactive apps without a frontend framework (or build system), HTMX lets you use HTML + HTTP to get the job done. In this tutorial, we build a minimal To-Do app using Go's standard library and HTMX.
This is for total beginners — copy the code, run it, and you'll have a working app in minutes.
What We’ll Build
- Add, toggle, and delete todos
- Server-rendered HTML templates
- HTMX for partial updates (no SPA, no build step)
Project Structure
.
├── main.go # Go HTTP server + handlers
├── views/
│ ├── layout.html
│ ├── index.html
│ └── _list.html # partial for todo list updates
└── static/
└── styles.css
Step 1 — Minimal Go Server
package main
import (
"html/template"
"log"
"net/http"
"strconv"
"sync"
)
type Todo struct {
ID int
Title string
Done bool
}
type Store struct {
mu sync.Mutex
seq int
todos []Todo
}
func (s *Store) Add(title string) {
s.mu.Lock(); defer s.mu.Unlock()
s.seq++
s.todos = append(s.todos, Todo{ID: s.seq, Title: title})
}
func (s *Store) Toggle(id int) {
s.mu.Lock(); defer s.mu.Unlock()
for i := range s.todos {
if s.todos[i].ID == id {
s.todos[i].Done = !s.todos[i].Done
return
}
}
}
func (s *Store) Delete(id int) {
s.mu.Lock(); defer s.mu.Unlock()
out := s.todos[:0]
for _, t := range s.todos {
if t.ID != id { out = append(out, t) }
}
s.todos = out
}
var (
store = &Store{}
tpl = template.Must(template.ParseGlob("views/*.html"))
)
func main() {
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if err := tpl.ExecuteTemplate(w, "index.html", store.todos); err != nil {
http.Error(w, err.Error(), 500)
}
})
mux.HandleFunc("/todos", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
if err := r.ParseForm(); err != nil { http.Error(w, "bad form", 400); return }
title := r.Form.Get("title")
if title != "" { store.Add(title) }
// Return the updated list partial for HTMX swap
if err := tpl.ExecuteTemplate(w, "_list.html", store.todos); err != nil { http.Error(w, err.Error(), 500) }
default:
http.Error(w, "method not allowed", 405)
}
})
mux.HandleFunc("/toggle", func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.URL.Query().Get("id"))
store.Toggle(id)
if err := tpl.ExecuteTemplate(w, "_list.html", store.todos); err != nil { http.Error(w, err.Error(), 500) }
})
mux.HandleFunc("/delete", func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.URL.Query().Get("id"))
store.Delete(id)
if err := tpl.ExecuteTemplate(w, "_list.html", store.todos); err != nil { http.Error(w, err.Error(), 500) }
})
log.Println("listening on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Step 2 — Views with HTMX
views/layout.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Go + HTMX To-Do</title>
<link rel="stylesheet" href="/static/styles.css" />
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>
<main>
{{ template "content" . }}
</main>
</body>
</html>
views/index.html
{{ define "content" }}
<h1>Go + HTMX To-Do</h1>
<form hx-post="/todos" hx-target="#list" hx-swap="outerHTML">
<input name="title" placeholder="Add a task" />
<button type="submit">Add</button>
</form>
<div id="list">
{{ template "_list.html" . }}
</div>
{{ end }}
views/_list.html
<ul>
{{ range . }}
<li>
<label>
<input type="checkbox"
{{ if .Done }}checked{{ end }}
hx-get="/toggle?id={{ .ID }}"
hx-target="#list"
hx-swap="outerHTML" />
<span class="{{ if .Done }}done{{ end }}">{{ .Title }}</span>
</label>
<button
hx-get="/delete?id={{ .ID }}"
hx-target="#list"
hx-swap="outerHTML">
✕
</button>
</li>
{{ else }}
<li>No todos yet — add one!</li>
{{ end }}
</ul>
static/styles.css
:root { color-scheme: light dark; }
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 2rem; }
main { max-width: 720px; margin: 0 auto; }
form { display: flex; gap: .5rem; margin: 1rem 0; }
input { flex: 1; padding: .5rem .75rem; }
button { padding: .5rem .75rem; cursor: pointer; }
ul { list-style: none; padding: 0; }
li { display: flex; align-items: center; justify-content: space-between; padding: .4rem 0; }
.done { text-decoration: line-through; color: gray; }
Step 3 — Run It
- Save the files as shown
- Run
go run main.go
- Open
http://localhost:8080
Why This Works (SEO + Simplicity)
- Fast server-rendered HTML, crawlable by default
- HTMX keeps interactions snappy without a JS framework
- Tiny codebase you can deploy anywhere
Want a more advanced UI with routing and assets? Read the sequel where we reuse the same Go backend and build a Next.js frontend: /writing/go-backend-nextjs-frontend.