Simple Go and HTMX TODO Application
This tutorial walks you through creating a basic TODO application using Go for the backend and HTMX for dynamic frontend interactions, all without writing a single line of JavaScript! We'll use a SQLite database for persistence, go-chi
for routing, and Go's standard html/template
package for rendering.
Setup
First, let's get our project organized and dependencies ready.
Project Structure
We'll use the following clear and concise project layout:
.
├── database/ // Handles database initialization and connection
│ └── db.go
├── go.mod // Go module file for dependencies
├── go.sum // Go module checksums
├── handler/ // Contains HTTP handler functions for our API endpoints
│ └── handler.go
├── main.go // The main entry point of our application
├── model/ // Defines our data structures and their database interactions
│ └── todo.go
└── templates/ // Stores our HTML templates
├── index.html // The main page template
└── partial/ // Partial templates for individual TODO items
├── deleted-todo.html
└── todo.html
Download Dependencies
Open your terminal in the project root and grab the necessary Go modules:
go get -u github.com/mattn/go-sqlite3 # SQLite database driver
go get -u github.com/go-chi/chi/v5 # Lightweight, idiomatic HTTP router
go get -u github.com/google/uuid # For generating unique IDs for TODOs
Create Our Entrypoint (main.go
)
This file sets up our HTTP server, initializes the database, defines our routes, and links them to our handler functions.
// main.go
package main
import (
"log" // For logging fatal errors
"net/http" // Standard HTTP package
"gotodo/database" // Our local database package
"gotodo/handler" // Our local HTTP handler package
"github.com/go-chi/chi/v5" // Chi router
_ "github.com/mattn/go-sqlite3" // SQLite driver import (underscore for side effects)
)
func main() {
// Initialize the Chi router
r := chi.NewRouter()
// Initialize our SQLite database
db := database.InitializeDatabase()
// Define our routes and link them to handler functions
// The handlers are wrapped in functions that take the database connection 'db'.
r.Get("/", handler.HandleGetIndex(db)) // GET /: Display all TODOs
r.Post("/", handler.HandlePostTodo(db)) // POST /: Add a new TODO
r.Delete("/", handler.HandleDeleteTodo(db)) // DELETE /: Delete a TODO
r.Patch("/", handler.HandlePatchCompleteTodo(db)) // PATCH /: Mark a TODO as complete
// Start the HTTP server on port 8080. Log any fatal errors.
log.Fatal(http.ListenAndServe(":8080", r))
}
Database Initialization (database/db.go
)
This package is responsible for opening the SQLite database connection and ensuring our todo
table exists.
// database/db.go
package database
import (
"database/sql" // Standard SQL package
"log" // For logging fatal errors
)
// InitializeDatabase opens a SQLite database connection and creates the 'todo' table if it doesn't exist.
func InitializeDatabase() *sql.DB {
// Open the SQLite database file. It will be created if it doesn't exist.
db, err := sql.Open("sqlite3", "file:./db.sqlite")
if err != nil {
log.Fatal(err) // Log and exit if there's an error opening the database
}
// Execute SQL to create the 'todo' table if it doesn't exist.
// 'id' is text primary key, 'content' is text, 'complete' defaults to 0 (false).
_, err = db.Exec(
`
create table if not exists todo (
id text primary key,
content text,
complete integer default 0
)
`,
)
if err != nil {
log.Fatal(err) // Log and exit if there's an error creating the table
}
return db // Return the initialized database connection
}
Models and Database Interaction (model/todo.go
)
Our model
package defines the Todo
struct, representing a single TODO item, and includes methods for interacting with the database (Create, Read, Update, Delete).
// model/todo.go
package model
import (
"database/sql" // Standard SQL package for database operations
"fmt" // For formatting error messages
"github.com/google/uuid" // For generating unique IDs
)
// Todo represents a single TODO item in our application.
type Todo struct {
ID string // Unique identifier for the TODO
Content string // The text content of the TODO
Complete int // 0 for incomplete, 1 for complete
}
// Create generates a new UUID for the Todo and inserts it into the database.
func (t *Todo) Create(db *sql.DB) error {
t.ID = uuid.NewString() // Generate a new unique ID
s, err := db.Prepare( // Prepare a SQL statement for insertion
`
insert into todo (id, content) values($1, $2) returning complete
`,
)
if err != nil {
return fmt.Errorf("failed to prepare create statement: %w", err)
}
// Execute the statement and scan the 'complete' value back into the struct
return s.QueryRow(t.ID, t.Content).Scan(&t.Complete)
}
// SetComplete marks a Todo as complete in the database.
func (t *Todo) SetComplete(db *sql.DB) error {
s, err := db.Prepare( // Prepare a SQL statement for updating
`
update todo
set complete = 1
where id = $1
returning id, content, complete
`,
)
if err != nil {
return fmt.Errorf("failed to prepare set complete statement: %w", err)
}
// Execute the update and scan the updated row back into the struct
return s.QueryRow(t.ID).Scan(&t.ID, &t.Content, &t.Complete)
}
// Delete removes a Todo from the database.
func (t *Todo) Delete(db *sql.DB) error {
s, err := db.Prepare( // Prepare a SQL statement for deletion
`
delete from todo
where id = $1
returning content
`,
)
if err != nil {
return fmt.Errorf("failed to prepare delete statement: %w", err)
}
// Execute the delete and scan the 'content' back (useful for confirmation)
return s.QueryRow(t.ID).Scan(&t.Content)
}
// ReadAllTodos retrieves all TODO items from the database, ordered by creation (implied by ID).
func ReadAllTodos(db *sql.DB) ([]Todo, error) {
todos := []Todo{} // Initialize an empty slice to hold our TODOs
// Corrected SQL query: "order" is a keyword, likely meant "ORDER BY".
// Using "ORDER BY id" to get a consistent order.
s, err := db.Prepare(
"select id, content, complete from todo order by id",
)
if err != nil {
return todos, fmt.Errorf("failed to prepare read all statement: %w", err)
}
rows, err := s.Query() // Execute the query
if err != nil {
return todos, fmt.Errorf("failed to query all todos: %w", err)
}
defer rows.Close() // Ensure rows are closed after function exits
for rows.Next() { // Iterate over the query results
todo := Todo{} // Create a new Todo struct for each row
// Scan row values into the Todo struct fields
if err := rows.Scan(&todo.ID, &todo.Content, &todo.Complete); err != nil {
return todos, fmt.Errorf("failed to scan todo row: %w", err)
}
todos = append(todos, todo) // Add the scanned todo to our slice
}
// Check for any errors that occurred during row iteration
if err := rows.Err(); err != nil {
return todos, fmt.Errorf("error during rows iteration: %w", err)
}
return todos, nil // Return the slice of TODOs
}
Note on ReadAllTodos
: The original SQL query select id, content, complete from todo order
had a typo. ORDER
is a keyword and needs an BY
clause. I've corrected it to order by id
to ensure a consistent retrieval order.
Handler Functions (handler/handler.go
)
This package contains our HTTP handler functions. Each function takes the *sql.DB
connection, allowing it to interact with our database models. We'll use Go's html/template
package to render HTML and slog
for structured logging.
// handler/handler.go
package handler
import (
"database/sql" // For database interaction
"encoding/json" // For decoding JSON requests (for POST)
"gotodo/model" // Our Todo model
"html/template" // Go's templating engine
"log/slog" // For structured logging
"net/http" // Standard HTTP package
)
// HandleGetIndex serves the main page with all existing TODOs.
func HandleGetIndex(db *sql.DB) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
todos, err := model.ReadAllTodos(db) // Read all TODOs from the database
if err != nil {
slog.Error("reading todos", "error", err) // Log the error
http.Error(w, "Failed to load todos", http.StatusInternalServerError) // Send generic error
return
}
// Parse our main index template and the todo partial template
tmpl := template.Must(template.ParseFiles(
"templates/partial/todo.html",
"templates/index.html",
))
// Execute the "index" template, passing the list of todos as data
if err := tmpl.ExecuteTemplate(w, "index", todos); err != nil {
slog.Error("executing template", "error", err)
http.Error(w, "Failed to render page", http.StatusInternalServerError)
return
}
})
}
// HandlePostTodo handles creating a new TODO item via an HTMX POST request.
func HandlePostTodo(db *sql.DB) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t := model.Todo{} // Create an empty Todo struct
// HTMX sends data as application/x-www-form-urlencoded by default,
// but with `hx-ext="json-enc"`, it sends JSON.
// So we decode JSON from the request body.
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
slog.Error("decoding json", "error", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := t.Create(db); err != nil { // Create the TODO in the database
slog.Error("creating todo", "error", err)
http.Error(w, "Failed to create todo", http.StatusInternalServerError)
return
}
// Parse only the todo partial template, as HTMX will swap this into the DOM
tmpl := template.Must(
template.ParseFiles("templates/partial/todo.html"),
)
// Execute the "todo" partial template with the newly created todo data
if err := tmpl.ExecuteTemplate(w, "todo", t); err != nil {
slog.Error("executing template", "error", err)
http.Error(w, "Failed to render todo item", http.StatusInternalServerError)
return
}
})
}
// HandlePatchCompleteTodo marks a specific TODO item as complete.
func HandlePatchCompleteTodo(db *sql.DB) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id") // Get the TODO ID from the URL query parameter
if id == "" {
http.Error(w, "Missing todo ID", http.StatusBadRequest)
return
}
t := model.Todo{ID: id} // Create a Todo struct with just the ID
if err := t.SetComplete(db); err != nil { // Mark as complete in the database
slog.Error("completing todo", "error", err, "id", id)
http.Error(w, "Failed to complete todo", http.StatusInternalServerError)
return
}
// Parse and execute the todo partial template to update the UI
tmpl := template.Must(
template.ParseFiles("templates/partial/todo.html"),
)
if err := tmpl.ExecuteTemplate(w, "todo", t); err != nil {
slog.Error("executing template", "error", err, "id", id)
http.Error(w, "Failed to render updated todo item", http.StatusInternalServerError)
return
}
})
}
// HandleDeleteTodo removes a specific TODO item.
func HandleDeleteTodo(db *sql.DB) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id") // Get the TODO ID from the URL query parameter
if id == "" {
http.Error(w, "Missing todo ID", http.StatusBadRequest)
return
}
t := model.Todo{ID: id} // Create a Todo struct with just the ID
if err := t.Delete(db); err != nil { // Delete the TODO from the database
slog.Error("deleting todo", "error", err, "id", id)
http.Error(w, "Failed to delete todo", http.StatusInternalServerError)
return
}
// Parse and execute the 'deleted-todo' partial template.
// HTMX will typically replace the deleted element with this, or remove it entirely based on hx-swap.
tmpl := template.Must(
template.ParseFiles("templates/partial/deleted-todo.html"),
)
if err := tmpl.ExecuteTemplate(w, "deleted-todo", t); err != nil {
slog.Error("executing template", "error", err, "id", id)
http.Error(w, "Failed to render deleted todo item", http.StatusInternalServerError)
}
})
}
HTML Templates (templates/
)
Finally, we set up our HTML templates. We'll have a main index.html
and two partials: todo.html
for displaying a single TODO item, and deleted-todo.html
for a visual placeholder after deletion.
Main Page (templates/index.html
)
This is the main entry point for our web application. It includes HTMX and Tailwind CSS for styling.
{{define "index"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://unpkg.com/htmx.org@1.9.5"></script>
<script src="https://unpkg.com/htmx.org/dist/ext/json-enc.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<title>Go TODO</title>
</head>
<body>
<main id="main" class="p-5 grid gap-5 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div class="border border-slate-300 rounded-md p-2">
<form
hx-post="/" hx-ext="json-enc" hx-target="#main" hx-swap="beforeend" hx-on::after-request="this.reset()" >
<textarea
class="border border-slate-300 rounded-md p-2 w-full resize-none h-32 focus:outline-none"
type="text"
placeholder="Content..."
name="content" ></textarea>
<button type="submit" class="border border-slate-300 rounded-md px-3 py-1">Add</button>
</form>
</div>
{{range .}} {{template "todo" .}} {{end}}
</main>
</body>
</html>
{{end}}
Partial: Individual TODO Item (templates/partial/todo.html
)
This template renders a single TODO item, including its content, and "Complete" and "Delete" buttons. HTMX attributes handle the dynamic updates.
{{define "todo"}}
<div
id="TODO{{.ID}}" class="flex flex-col justify-between border rounded-md p-2
{{if eq .Complete 1}}border-green-500{{else}}border-slate-300{{end}}
min-h-[100px]"
>
<div>
<p>{{.Content}}</p> </div>
{{if eq .Complete 0}} <div class="flex justify-between items-center mt-2">
<button
hx-patch="/?id={{.ID}}" hx-target="#TODO{{.ID}}" hx-swap="outerHTML" class="border border-slate-300 rounded-md px-3 py-1"
>
Complete
</button>
<button
hx-delete="/?id={{.ID}}" hx-target="#TODO{{.ID}}" hx-swap="outerHTML" class="border border-red-500 rounded-md px-3 py-1"
>
Delete
</button>
</div>
{{end}}
</div>
{{end}}
Partial: Deleted TODO Placeholder (templates/partial/deleted-todo.html
)
This template provides a visual representation for a deleted TODO item, typically appearing faded or indicating its removal.
{{define "deleted-todo"}}
<div
id="TODO{{.ID}}" class="relative flex flex-col justify-between border rounded-md p-2 border-slate-300 opacity-40 min-h-[100px] transition-opacity duration-500"
>
<div>
<p>{{.Content}}</p> <p class="text-sm text-red-500 mt-1">Deleted</p> </div>
</div>
{{end}}
Running the Application
- Save all the files into their respective paths and directories.
- Open your terminal in the root of your project (
gotodo/
). - Run the application:
go run main.go
- Open your web browser and navigate to
http://localhost:8080
.
You should now see a simple TODO application. You can:
- Add new TODOs using the form. They'll appear dynamically without a full page refresh.
- Mark TODOs as complete. The border will change color, and the buttons will disappear.
- Delete TODOs. They will be replaced by a faded placeholder indicating deletion.
This example demonstrates how Go and HTMX create a powerful, efficient, and surprisingly simple way to build dynamic web applications with minimal JavaScript, leveraging the strengths of server-side rendering and hypermedia.
What other dynamic web features would you like to add to this application using Go and HTMX?