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.