Build a To-Do App in Go + HTMX (Step-by-Step for Beginners)

July 22, 2025

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.