HTMX with Go

This guide shows how to deploy a small HTMX + Go app with Haloy on your own server.

The complete source code for this guide is available at: haloydev/examples/htmx

What You’ll Build

You will deploy a server-rendered todo app built with:

  • Go - Backend server and routing
  • HTMX - In-page updates without building a separate SPA
  • HTML templates - Full-page rendering plus fragment responses
  • Haloy - Deployment to your own server

Prerequisites

  • Go 1.25+ installed
  • Haloy installed (Quickstart)
  • A Linux server (VPS or dedicated server)
  • A domain or a subdomain
  • Basic familiarity with Go and HTML

Project Setup

1. Create the Project

mkdir my-htmx-app cd my-htmx-app go mod init htmx-example

2. Create Project Files

Create this file structure:

. ├── Dockerfile ├── go.mod ├── haloy.yaml ├── main.go └── templates └── index.html

Application Code

1. Create the Go Server

Create main.go:

package main import ( "embed" "html/template" "log" "net/http" "strconv" "sync" ) //go:embed templates var templateFS embed.FS var tmpl = template.Must(template.ParseFS(templateFS, "templates/*.html")) type Todo struct { ID int Text string Done bool } type App struct { mu sync.Mutex todos []Todo nextID int } func main() { app := &App{ todos: []Todo{ {ID: 1, Text: "Learn HTMX", Done: false}, {ID: 2, Text: "Build something with Go", Done: true}, }, nextID: 3, } mux := http.NewServeMux() mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status":"ok"}`)) }) mux.HandleFunc("GET /", app.handleIndex) mux.HandleFunc("POST /todos", app.handleAddTodo) mux.HandleFunc("PUT /todos/{id}/toggle", app.handleToggleTodo) mux.HandleFunc("DELETE /todos/{id}", app.handleDeleteTodo) log.Println("Server starting on :8080") log.Fatal(http.ListenAndServe(":8080", mux)) } func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) { a.mu.Lock() defer a.mu.Unlock() tmpl.ExecuteTemplate(w, "index.html", a.todos) } func (a *App) handleAddTodo(w http.ResponseWriter, r *http.Request) { text := r.FormValue("text") if text == "" { http.Error(w, "text is required", http.StatusBadRequest) return } a.mu.Lock() defer a.mu.Unlock() todo := Todo{ID: a.nextID, Text: text, Done: false} a.nextID++ a.todos = append(a.todos, todo) tmpl.ExecuteTemplate(w, "todo-item", todo) } func (a *App) handleToggleTodo(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) if err != nil { http.Error(w, "invalid id", http.StatusBadRequest) return } a.mu.Lock() defer a.mu.Unlock() for i := range a.todos { if a.todos[i].ID == id { a.todos[i].Done = !a.todos[i].Done tmpl.ExecuteTemplate(w, "todo-item", a.todos[i]) return } } http.Error(w, "not found", http.StatusNotFound) } func (a *App) handleDeleteTodo(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) if err != nil { http.Error(w, "invalid id", http.StatusBadRequest) return } a.mu.Lock() defer a.mu.Unlock() for i := range a.todos { if a.todos[i].ID == id { a.todos = append(a.todos[:i], a.todos[i+1:]...) w.WriteHeader(http.StatusOK) return } } http.Error(w, "not found", http.StatusNotFound) }

2. Create the HTMX Template

Create templates/index.html:

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>HTMX + Go Todo App</title> <script src="https://unpkg.com/[email protected]"></script> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; background: #f5f5f5; color: #333; } h1 { margin-bottom: 24px; } form { display: flex; gap: 8px; margin-bottom: 24px; } input[type="text"] { flex: 1; padding: 10px 14px; border: 1px solid #ccc; border-radius: 6px; font-size: 16px; } button { padding: 10px 18px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; } button[type="submit"] { background: #2563eb; color: white; } button[type="submit"]:hover { background: #1d4ed8; } .todo-list { list-style: none; display: flex; flex-direction: column; gap: 8px; } .todo-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } .todo-item.done .todo-text { text-decoration: line-through; color: #999; } .todo-text { flex: 1; font-size: 16px; } .btn-toggle { background: #e5e7eb; font-size: 18px; width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; padding: 0; } .btn-delete { background: #fee2e2; color: #dc2626; font-size: 14px; } .btn-delete:hover { background: #fecaca; } </style> </head> <body> <h1>Todo App</h1> <form hx-post="/todos" hx-target="#todo-list" hx-swap="beforeend" hx-on::after-request="if(event.detail.successful) this.reset()"> <input type="text" name="text" placeholder="What needs to be done?" required> <button type="submit">Add</button> </form> <ul id="todo-list" class="todo-list"> {{range .}} {{template "todo-item" .}} {{end}} </ul> </body> </html> {{define "todo-item"}} <li id="todo-{{.ID}}" class="todo-item{{if .Done}} done{{end}}"> <button class="btn-toggle" hx-put="/todos/{{.ID}}/toggle" hx-target="#todo-{{.ID}}" hx-swap="outerHTML"> {{if .Done}}&#10003;{{else}}&nbsp;{{end}} </button> <span class="todo-text">{{.Text}}</span> <button class="btn-delete" hx-delete="/todos/{{.ID}}" hx-target="#todo-{{.ID}}" hx-swap="outerHTML">Delete</button> </li> {{end}}

Why this setup works well:

  • The server renders both full pages and small HTML fragments
  • HTMX swaps only the changed todo row
  • You do not need a separate frontend build step

Docker Configuration

Create Dockerfile:

FROM golang:1.25-alpine AS builder WORKDIR /app COPY go.mod ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o server . FROM alpine:3.21 COPY --from=builder /app/server /server EXPOSE 8080 HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ CMD wget -qO- http://localhost:8080/health || exit 1 USER nobody CMD ["/server"]

Haloy Configuration

Create haloy.yaml:

name: htmx server: haloy.example.com domains: - domain: my-app.example.com port: 8080

Before deploying, replace:

  • server - Your Haloy server domain
  • domains[0].domain - The domain (or subdomain) for your app

Deploy

Run deployment from the project root:

haloy deploy

Haloy builds the Docker image, uploads it to your server, starts the container, and configures routing for your domain.

Verify the Deployment

After deployment:

  1. Open your configured domain in a browser
  2. Add a todo item and verify it appears without a full page refresh
  3. Toggle and delete todos to confirm HTMX interactions are working
  4. Check the health endpoint:
curl https://my-app.example.com/health

You should get:

{"status":"ok"}

Stay updated on Haloy

Get notified about new docs, deployment patterns, and Haloy updates.