Building a Trip Tracker Web App with Go, Templ, and HTMX

Building a Trip Tracker Web App with Go, Templ, and HTMX

In this tutorial, we'll build a simple trip tracking web application using Go's standard library, Templ for templates, HTMX for dynamic interactions, and Tailwind CSS for styling. By the end, you'll have a working web app that can create, read, update, and delete trips.

What We'll Build

A basic trip tracker where users can:

  • View a list of their trips
  • Add new trips with origin and destination
  • Edit existing trips
  • Delete trips

Prerequisites

  • Go 1.23+ installed
  • Basic knowledge of Go and HTML
  • SQLite3 installed

Project Setup

Let's start by creating our project structure:

trip-tracker/
├── main.go
├── go.mod
├── internal/
│   ├── database/
│   │   ├── database.go
│   │   └── schema.sql
│   └── handlers/
│       ├── home.go
│       ├── trips.go
│       └── trip_handlers.go
├── templates/
│   ├── layout.templ
│   └── trips.templ
└── static/
    ├── css/
    │   ├── input.css
    │   └── output.css
    └── js/
        └── htmx.min.js

Step 1: Initialize the Project

1mkdir trip-tracker && cd trip-tracker
2go mod init trip-tracker

Install required dependencies:

Install SQLite (Quick and reliable storage):

1sudo apt update
2sudo apt install sqlite3
3sqlite3 --version

Install Templ (For Template generation):

1go get github.com/a-h/templ/cmd/templ@latest

Step 2: Database Setup

Create the database schema in internal/database/schema.sql:

 1CREATE TABLE IF NOT EXISTS trips (
 2    id INTEGER PRIMARY KEY AUTOINCREMENT,
 3    title TEXT NOT NULL,
 4    origin TEXT NOT NULL,
 5    destination TEXT NOT NULL,
 6    start_date TEXT NOT NULL,
 7    end_date TEXT NOT NULL,
 8    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
 9);
10
11-- Insert some sample data
12INSERT INTO trips (title, origin, destination, start_date, end_date) VALUES
13('Tokyo Adventure', 'LAX', 'NRT', '2024-03-15', '2024-03-25'),
14('European Tour', 'JFK', 'LHR', '2024-06-01', '2024-06-15');

Create the database connection in internal/database/database.go:

 1package database
 2
 3import (
 4    "database/sql"
 5    "os"
 6
 7    _ "github.com/mattn/go-sqlite3"
 8)
 9
10type Trip struct {
11    ID          int    `json:"id"`
12    Title       string `json:"title"`
13    Origin      string `json:"origin"`
14    Destination string `json:"destination"`
15    StartDate   string `json:"start_date"`
16    EndDate     string `json:"end_date"`
17    CreatedAt   string `json:"created_at"`
18}
19
20type DB struct {
21    conn *sql.DB
22}
23
24func NewDB() (*DB, error) {
25    conn, err := sql.Open("sqlite3", "./trips.db")
26    if err != nil {
27        return nil, err
28    }
29
30    db := &DB{conn: conn}
31    if err := db.createTables(); err != nil {
32        return nil, err
33    }
34
35    return db, nil
36}
37
38func (db *DB) createTables() error {
39    schema, err := os.ReadFile("internal/database/schema.sql")
40    if err != nil {
41        return err
42    }
43
44    _, err = db.conn.Exec(string(schema))
45    return err
46}
47
48func (db *DB) GetAllTrips() ([]Trip, error) {
49    rows, err := db.conn.Query("SELECT id, title, origin, destination, start_date, end_date, created_at FROM trips ORDER BY start_date DESC")
50    if err != nil {
51        return nil, err
52    }
53    defer rows.Close()
54
55    var trips []Trip
56    for rows.Next() {
57        var trip Trip
58        err := rows.Scan(&trip.ID, &trip.Title, &trip.Origin, &trip.Destination, &trip.StartDate, &trip.EndDate, &trip.CreatedAt)
59        if err != nil {
60            return nil, err
61        }
62        trips = append(trips, trip)
63    }
64
65    return trips, nil
66}
67
68func (db *DB) CreateTrip(trip Trip) error {
69    _, err := db.conn.Exec(
70        "INSERT INTO trips (title, origin, destination, start_date, end_date) VALUES (?, ?, ?, ?, ?)",
71        trip.Title, trip.Origin, trip.Destination, trip.StartDate, trip.EndDate,
72    )
73    return err
74}
75
76func (db *DB) UpdateTrip(trip Trip) error {
77    _, err := db.conn.Exec(
78        "UPDATE trips SET title = ?, origin = ?, destination = ?, start_date = ?, end_date = ? WHERE id = ?",
79        trip.Title, trip.Origin, trip.Destination, trip.StartDate, trip.EndDate, trip.ID,
80    )
81    return err
82}
83
84func (db *DB) DeleteTrip(id int) error {
85    _, err := db.conn.Exec("DELETE FROM trips WHERE id = ?", id)
86    return err
87}
88
89func (db *DB) GetTripByID(id int) (Trip, error) {
90    var trip Trip
91    err := db.conn.QueryRow(
92        "SELECT id, title, origin, destination, start_date, end_date, created_at FROM trips WHERE id = ?", id,
93    ).Scan(&trip.ID, &trip.Title, &trip.Origin, &trip.Destination, &trip.StartDate, &trip.EndDate, &trip.CreatedAt)
94
95    return trip, err
96}

Step 3: Templ Templates

First, install the Templ CLI:

1go install github.com/a-h/templ/cmd/templ@latest

Create the base layout in templates/layout.templ:

package templates

templ Layout(contents templ.Component, title string) {
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>{ title } - Trip Tracker</title>
        <script src="https://unpkg.com/htmx.org@1.9.10"></script>
        <script src="https://cdn.tailwindcss.com"></script>
    </head>
    <body class="bg-gray-50 min-h-screen">
        <nav class="bg-blue-600 text-white p-4">
            <div class="container mx-auto">
                <h1 class="text-2xl font-bold">Trip Tracker</h1>
            </div>
        </nav>

        <main class="container mx-auto p-4">
            @contents
        </main>
    </body>
    </html>
}

The @contents will serve all the other template compnents, allowing you to have a nice base for your entire website.

Create the trips template in templates/trips.templ:

package templates

import "trip-tracker/internal/database"

templ TripsPage(trips []database.Trip) {
    <div class="max-w-4xl mx-auto">
        <div class="flex justify-between items-center mb-6">
            <h2 class="text-3xl font-bold text-gray-800">My Trips</h2>
            <button
                hx-get="/trips/new"
                hx-target="#modal"
                class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg">
                Add New Trip
            </button>
        </div>

        <div id="trips-list" class="space-y-4">
            @TripsList(trips)
        </div>
    </div>

    <!-- Modal for forms -->
    <div id="modal"></div>
}

templ TripsList(trips []database.Trip) {
    for _, trip := range trips {
        @TripCard(trip)
    }
}

templ TripCard(trip database.Trip) {
    <div class="bg-white rounded-lg shadow-md p-6 border-l-4 border-blue-500">
        <div class="flex justify-between items-start">
            <div>
                <h3 class="text-xl font-semibold text-gray-800 mb-2">{ trip.Title }</h3>
                <div class="text-gray-600 space-y-1">
                    <p><span class="font-medium">Route:</span> { trip.Origin } → { trip.Destination }</p>
                    <p><span class="font-medium">Dates:</span> { trip.StartDate } to { trip.EndDate }</p>
                </div>
            </div>
            <div class="flex space-x-2">
                <button
                    hx-get={ "/trips/" + templ.EscapeString(string(rune(trip.ID))) + "/edit" }
                    hx-target="#modal"
                    class="text-blue-600 hover:text-blue-800">
                    Edit
                </button>
                <button
                    hx-delete={ "/trips/" + templ.EscapeString(string(rune(trip.ID))) }
                    hx-target="closest div"
                    hx-swap="outerHTML"
                    hx-confirm="Are you sure you want to delete this trip?"
                    class="text-red-600 hover:text-red-800">
                    Delete
                </button>
            </div>
        </div>
    </div>
}

templ TripForm(trip database.Trip, isEdit bool) {
    <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
        <div class="bg-white rounded-lg p-6 w-full max-w-md">
            <h3 class="text-xl font-semibold mb-4">
                if isEdit {
                    Edit Trip
                } else {
                    Add New Trip
                }
            </h3>

            <form
                if isEdit {
                    hx-put={ "/trips/" + templ.EscapeString(string(rune(trip.ID))) }
                } else {
                    hx-post="/trips"
                }
                hx-target="#trips-list"
                hx-swap="innerHTML">

                <div class="space-y-4">
                    <div>
                        <label class="block text-sm font-medium text-gray-700 mb-1">Title</label>
                        <input
                            type="text"
                            name="title"
                            value={ trip.Title }
                            required
                            class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
                    </div>

                    <div>
                        <label class="block text-sm font-medium text-gray-700 mb-1">Origin</label>
                        <input
                            type="text"
                            name="origin"
                            value={ trip.Origin }
                            required
                            class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
                    </div>

                    <div>
                        <label class="block text-sm font-medium text-gray-700 mb-1">Destination</label>
                        <input
                            type="text"
                            name="destination"
                            value={ trip.Destination }
                            required
                            class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
                    </div>

                    <div>
                        <label class="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
                        <input
                            type="date"
                            name="start_date"
                            value={ trip.StartDate }
                            required
                            class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
                    </div>

                    <div>
                        <label class="block text-sm font-medium text-gray-700 mb-1">End Date</label>
                        <input
                            type="date"
                            name="end_date"
                            value={ trip.EndDate }
                            required
                            class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
                    </div>
                </div>

                <div class="flex justify-end space-x-3 mt-6">
                    <button
                        type="button"
                        onclick="document.getElementById('modal').innerHTML = ''"
                        class="px-4 py-2 text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50">
                        Cancel
                    </button>
                    <button
                        type="submit"
                        class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
                        if isEdit {
                            Update Trip
                        } else {
                            Create Trip
                        }
                    </button>
                </div>
            </form>
        </div>
    </div>
}

Here, we split trips into multiple components, each with their own role:

  • TripsPage: Main trip page, hosts the actual component and base page when going to /trips
  • TripsList: Lists component, holds the html/css needed as the list wrapper for all the TripCard components
  • TripCard: HTML component to hold the actual trip information
  • TripForm: Form component to submit trips to the website

Generate the template files. These files will be generate to .go files. Do not try to edit them yourself!:

1templ generate

Step 4: HTTP Handlers

Create the main handlers in internal/handlers/trips.go:

  1package handlers
  2
  3import (
  4    "net/http"
  5    "strconv"
  6
  7    "trip-tracker/internal/database"
  8    "trip-tracker/templates"
  9)
 10
 11type TripHandler struct {
 12    db *database.DB
 13}
 14
 15func NewTripHandler(db *database.DB) *TripHandler {
 16    return &TripHandler{db: db}
 17}
 18
 19func (h *TripHandler) HomePage(w http.ResponseWriter, r *http.Request) {
 20    trips, err := h.db.GetAllTrips()
 21    if err != nil {
 22        http.Error(w, "Failed to fetch trips", http.StatusInternalServerError)
 23        return
 24    }
 25
 26    component := templates.TripsPage(trips)
 27    templates.Layout(component, "Home").Render(r.Context(), w) // The TripsPage component is rendered under the Layout component
 28}
 29
 30func (h *TripHandler) GetTrips(w http.ResponseWriter, r *http.Request) {
 31    trips, err := h.db.GetAllTrips()
 32    if err != nil {
 33        http.Error(w, "Failed to fetch trips", http.StatusInternalServerError)
 34        return
 35    }
 36
 37    templates.TripsList(trips).Render(r.Context(), w)
 38}
 39
 40func (h *TripHandler) NewTripForm(w http.ResponseWriter, r *http.Request) {
 41    trip := database.Trip{}
 42    templates.TripForm(trip, false).Render(r.Context(), w)
 43}
 44
 45func (h *TripHandler) CreateTrip(w http.ResponseWriter, r *http.Request) {
 46    if err := r.ParseForm(); err != nil {
 47        http.Error(w, "Failed to parse form", http.StatusBadRequest)
 48        return
 49    }
 50
 51    trip := database.Trip{
 52        Title:       r.FormValue("title"),
 53        Origin:      r.FormValue("origin"),
 54        Destination: r.FormValue("destination"),
 55        StartDate:   r.FormValue("start_date"),
 56        EndDate:     r.FormValue("end_date"),
 57    }
 58
 59    if err := h.db.CreateTrip(trip); err != nil {
 60        http.Error(w, "Failed to create trip", http.StatusInternalServerError)
 61        return
 62    }
 63
 64    // Clear the modal and refresh the trips list
 65    w.Header().Set("HX-Trigger", "closeModal")
 66    h.GetTrips(w, r)
 67}
 68
 69func (h *TripHandler) EditTripForm(w http.ResponseWriter, r *http.Request) {
 70    idStr := r.URL.Path[len("/trips/"):]
 71    idStr = idStr[:len(idStr)-len("/edit")]
 72
 73    id, err := strconv.Atoi(idStr)
 74    if err != nil {
 75        http.Error(w, "Invalid trip ID", http.StatusBadRequest)
 76        return
 77    }
 78
 79    trip, err := h.db.GetTripByID(id)
 80    if err != nil {
 81        http.Error(w, "Trip not found", http.StatusNotFound)
 82        return
 83    }
 84
 85    templates.TripForm(trip, true).Render(r.Context(), w)
 86}
 87
 88func (h *TripHandler) UpdateTrip(w http.ResponseWriter, r *http.Request) {
 89    idStr := r.PathValue("id")
 90    id, err := strconv.Atoi(idStr)
 91    if err != nil {
 92        http.Error(w, "Invalid trip ID", http.StatusBadRequest)
 93        return
 94    }
 95
 96    if err := r.ParseForm(); err != nil {
 97        http.Error(w, "Failed to parse form", http.StatusBadRequest)
 98        return
 99    }
100
101    trip := database.Trip{
102        ID:          id,
103        Title:       r.FormValue("title"),
104        Origin:      r.FormValue("origin"),
105        Destination: r.FormValue("destination"),
106        StartDate:   r.FormValue("start_date"),
107        EndDate:     r.FormValue("end_date"),
108    }
109
110    if err := h.db.UpdateTrip(trip); err != nil {
111        http.Error(w, "Failed to update trip", http.StatusInternalServerError)
112        return
113    }
114
115    w.Header().Set("HX-Trigger", "closeModal")
116    h.GetTrips(w, r)
117}
118
119func (h *TripHandler) DeleteTrip(w http.ResponseWriter, r *http.Request) {
120    idStr := r.URL.Path[len("/trips/"):]
121    id, err := strconv.Atoi(idStr)
122    if err != nil {
123        http.Error(w, "Invalid trip ID", http.StatusBadRequest)
124        return
125    }
126
127    if err := h.db.DeleteTrip(id); err != nil {
128        http.Error(w, "Failed to delete trip", http.StatusInternalServerError)
129        return
130    }
131
132    // Return empty response to remove the element
133    w.WriteHeader(http.StatusOK)
134}

Step 5: Main Application

Create main.go:

 1package main
 2
 3import (
 4    "log"
 5    "net/http"
 6    "os"
 7    "strings"
 8
 9    "trip-tracker/internal/database"
10    "trip-tracker/internal/handlers"
11)
12
13func main() {
14    // Initialize database
15    db, err := database.NewDB()
16    if err != nil {
17        log.Fatal("Failed to initialize database:", err)
18    }
19
20    // Initialize handlers
21    tripHandler := handlers.NewTripHandler(db)
22
23    // Routes
24    http.HandleFunc("/", tripHandler.HomePage)
25    http.HandleFunc("/trips/new", tripHandler.NewTripForm)
26
27    // Trip collection endpoints
28    http.HandleFunc("GET /trips", tripHandler.GetTrips)
29    http.HandleFunc("POST /trips", tripHandler.CreateTrip)
30
31    // Individual trip endpoints
32    http.HandleFunc("PUT /trips/{id}", tripHandler.UpdateTrip)
33    http.HandleFunc("DELETE /trips/{id}", tripHandler.DeleteTrip)
34
35    // Trip edit form endpoint
36    http.HandleFunc("GET /trips/{id}/edit", tripHandler.EditTripForm)
37
38    // Get port from environment or default to 8080
39    port := os.Getenv("PORT")
40    if port == "" {
41        port = "8080"
42    }
43
44    log.Printf("Server starting on port %s", port)
45    log.Fatal(http.ListenAndServe(":"+port, nil))
46}

Step 6: Running the Application

Generate templates and run the server:

1# Generate Templ templates
2templ generate
3
4# Run the application
5go run main.go

Visit http://localhost:8080 to see your trip tracker in action!

How It Works

HTMX Magic

  • hx-get: Fetches content and replaces target elements
  • hx-post/hx-put/hx-delete: Sends form data using different HTTP methods
  • hx-target: Specifies where to put the response
  • hx-swap: Controls how content is replaced

Templ Benefits

  • Type-safe templates with Go syntax
  • Compile-time validation
  • No runtime template parsing overhead
  • IntelliSense support in modern editors

Database Layer

  • Simple CRUD operations with SQLite
  • Prepared statements for security
  • Clean separation of concerns

What's Next?

This basic setup gives you a solid foundation. In future posts, we could explore:

  • Middleware: Authentication, logging, and CORS
  • Advanced HTMX: Real-time updates, infinite scroll
  • Database Migrations: Versioned schema changes
  • Testing: Unit and integration tests
  • Deployment: Docker containers and cloud hosting

The beauty of this stack is its simplicity - you get modern web app features without the complexity of heavy frameworks. Go's standard library, combined with HTMX and Templ, creates a powerful and maintainable web application.