One question I see going around alot about HTMX, especially amongst developers that have just tried the library is “But what can you really build with it though?”
Great question, and in this article, we will start with baby steps by building a database-backed CRUD application with HTMX and Go as our backend language.
By the way, if you really want a practical project-based guide on building fullstack apps with HTMX, check out my **HTMX + Go: Build Fullstack Applications with Golang and HTMX [Discount included] course.**
Let’s begin.
What exactly are we building?
I’ll like to call it a Task Management Application but I know you already figured that that’s just a fancy name for another Todo application. Don’t worry, Todo apps are great for learning fundamental operations with languages, libraries and frameworks so we will be using that same tested and trusted strategy.
Our application will be able to do the following:
- Display tasks
- Add new tasks
- Update an existing task and …
- Delete a task
Database Setup
So first, we need a database, and for this demo project, I will be using MySQL. Feel free to use any database of your choice and make the necessary code changes to reference your database as you follow along with this article.
We will keep things simple, no complicated schema design. First we create a database with the name testdb
and inside this database, we create a todos
table (feel free to give your database and table any name you prefer but ensure you use the same names in your SQL statements)
Inside the todos
table, implement the schema below:
-
id
: PK, Auto incrementing -
task
: VARCHAR(200) - Contains the task item -
done
: INT(1), default = 0 (Boolean field)
You can choose to seed the database table with some tasks so that we can start seeing some tasks the first time we load the application.
Creating the Hypermedia API
To begin setting up our little application, create a folder for the project at any convenient location in your development computer.
mkdir task-management
Run the following command at the root of the project folder to initialize it as a Golang project:
go mod init task-management
Next, we need to install some dependencies. We already know we are using MySQL as our database, thus, we need to install the MySQL driver for Golang.
We also need to install the Gorilla Mux Router which will be the routing library for our project. Run the two commands below at the root of your project to get these libraries installed into your project
MySQL:
go get -u github.com/go-sql-driver/mysql
Gorilla Mux:
go get -u github.com/gorilla/mux
With these libraries in place, create your main.go
file at the root of the project and add the code below:
package main
import (
"database/sql"
"fmt"
"html/template"
"log"
"net/http"
"strconv"
"strings"
_ "github.com/go-sql-driver/mysql"
"github.com/gorilla/mux"
)
var tmpl *template.Template
var db *sql.DB
type Task struct {
Id int
Task string
Done bool
}
func init() {
tmpl, _ = template.ParseGlob("templates/*.html")
}
func initDB() {
var err error
// Initialize the db variable
db, err = sql.Open("mysql", "root:root@(127.0.0.1:3333)/testdb?parseTime=true")
if err != nil {
log.Fatal(err)
}
// Check the database connection
if err = db.Ping(); err != nil {
log.Fatal(err)
}
}
func main() {
gRouter := mux.NewRouter()
//Setup MySQL
initDB()
defer db.Close()
gRouter.HandleFunc("/", Homepage)
//Get Tasks
gRouter.HandleFunc("/tasks", fetchTasks).Methods("GET")
//Fetch Add Task Form
gRouter.HandleFunc("/newtaskform", getTaskForm)
//Add Task
gRouter.HandleFunc("/tasks", addTask).Methods("POST")
//Fetch Update Form
gRouter.HandleFunc("/gettaskupdateform/{id}", getTaskUpdateForm).Methods("GET")
//Update Task
gRouter.HandleFunc("/tasks/{id}", updateTask).Methods("PUT", "POST")
//Delete Task
gRouter.HandleFunc("/tasks/{id}", deleteTask).Methods("DELETE")
http.ListenAndServe(":4000", gRouter)
}
func Homepage(w http.ResponseWriter, r *http.Request) {
tmpl.ExecuteTemplate(w, "home.html", nil)
}
func fetchTasks(w http.ResponseWriter, r *http.Request) {
todos, _ := getTasks(db)
//fmt.Println(todos)
//If you used "define" to define the template, use the name you gave it here, not the filename
tmpl.ExecuteTemplate(w, "todoList", todos)
}
func getTaskForm(w http.ResponseWriter, r *http.Request) {
tmpl.ExecuteTemplate(w, "addTaskForm", nil)
}
func addTask(w http.ResponseWriter, r *http.Request) {
task := r.FormValue("task")
fmt.Println(task)
query := "INSERT INTO tasks (task, done) VALUES (?, ?)"
stmt, err := db.Prepare(query)
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
_, executeErr := stmt.Exec(task, 0)
if executeErr != nil {
log.Fatal(executeErr)
}
// Return a new list of Todos
todos, _ := getTasks(db)
//You can also just send back the single task and append it
//I like returning the whole list just to get everything fresh, but this might not be the best strategy
tmpl.ExecuteTemplate(w, "todoList", todos)
}
func getTaskUpdateForm(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
//Convert string id from URL to integer
taskId, _ := strconv.Atoi(vars["id"])
task, err := getTaskByID(db, taskId)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
tmpl.ExecuteTemplate(w, "updateTaskForm", task)
}
func updateTask(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
taskItem := r.FormValue("task")
//taskStatus, _ := strconv.ParseBool(r.FormValue("done"))
var taskStatus bool
fmt.Println(r.FormValue("done"))
//Check the string value of the checkbox
switch strings.ToLower(r.FormValue("done")) {
case "yes", "on":
taskStatus = true
case "no", "off":
taskStatus = false
default:
taskStatus = false
}
taskId, _ := strconv.Atoi(vars["id"])
task := Task{
taskId, taskItem, taskStatus,
}
updateErr := updateTaskById(db, task)
if updateErr != nil {
log.Fatal(updateErr)
}
//Refresh all Tasks
todos, _ := getTasks(db)
tmpl.ExecuteTemplate(w, "todoList", todos)
}
func deleteTask(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
taskId, _ := strconv.Atoi(vars["id"])
err := deleTaskWithID(db, taskId)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
//Return list
todos, _ := getTasks(db)
tmpl.ExecuteTemplate(w, "todoList", todos)
}
func getTasks(dbPointer *sql.DB) ([]Task, error) {
query := "SELECT id, task, done FROM tasks"
rows, err := dbPointer.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var tasks []Task
for rows.Next() {
var todo Task
rowErr := rows.Scan(&todo.Id, &todo.Task, &todo.Done)
if rowErr != nil {
return nil, err
}
tasks = append(tasks, todo)
}
if err = rows.Err(); err != nil {
return nil, err
}
return tasks, nil
}
func getTaskByID(dbPointer *sql.DB, id int) (*Task, error) {
query := "SELECT id, task, done FROM tasks WHERE id = ?"
var task Task
row := dbPointer.QueryRow(query, id)
err := row.Scan(&task.Id, &task.Task, &task.Done)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("No task was found with task %d", id)
}
return nil, err
}
return &task, nil
}
func updateTaskById(dbPointer *sql.DB, task Task) error {
query := "UPDATE tasks SET task = ?, done = ? WHERE id = ?"
result, err := dbPointer.Exec(query, task.Task, task.Done, task.Id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
fmt.Println("No rows updated")
} else {
fmt.Printf("%d row(s) updated\n", rowsAffected)
}
return nil
}
func deleTaskWithID(dbPointer *sql.DB, id int) error {
query := "DELETE FROM tasks WHERE id = ?"
stmt, err := dbPointer.Prepare(query)
if err != nil {
return err
}
defer stmt.Close()
result, err := stmt.Exec(id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return fmt.Errorf("no task found with id %d", id)
}
fmt.Printf("Deleted %d task(s)\n", rowsAffected)
return nil
}
Yeah, that was alot of code. Don’t worry, we will take it from the very top and walk down
So first we import all our necessary packages. The MySQL driver and Gorilla Mux router we installed, and a bunch of packages from the Go standard library that will be useful in our code operations.
import (
"database/sql"
"fmt"
"html/template"
"log"
"net/http"
"strconv"
"strings"
_ "github.com/go-sql-driver/mysql"
"github.com/gorilla/mux"
)
Next, we create a tmpl
variable that will be used to hold our loaded templates and a db
variable that will be a pointer to our database connection for running database tasks. We then create a custom Task
struct that defines a task type.
Inside the init()
function, we load all our templates from a templates
folder. All our templates are expected to have the .html
extension as since HTMX expects us to return HTML, this makes a ton of sense.
Go ahead and create the templates
folder at the root of the project so that we can begin loading all our templates from there.
We also have an initDB()
function that takes care of setting up our connection to the database and returns a pointer reference to our database. Ensure to change the connection string to match that of your database (credentials, host, port, database name etc)
Inside the main
function, we initialize our router and call our initDB()
database function to initialize our database. This is then followed by all our routes and route handlers and finally, we listen on port 4000
which is where we will be serving the application.
Routes and Handlers
Now let’s begin breaking down our routes and their respective handlers.
-
The
GET /
Base Route: This is our base route and loads the home page of the application. The handler,Hompage
returns thehome.html
file to the client. -
The
GET /tasks
Route: This route uses thefetchTasks
handler to get all our tasks from our database and return them in an HTML list to the client using atodoList
template. -
The
GET /newtaskform
Route: This route will load a new task form from the server each time a user wants to create a new task or clicks a Add New Task button. It uses aaddTaskForm
template to display a new HTML form for adding a new task -
The
POST /tasks
Route: This route calls theaddTask
handler to add a new task to the database and return an updated list of all tasks. -
The
GET /gettaskupdateform/{id}
Route: Uses theId
of a task to load the task into an update form with theupdateTaskForm
template and returns this form to the client when the user clicks the Edit button. -
The
PUT/POST /tasks/{id}
Route: Takes theId
of a task to be updated and updates it using theupdateTask
handler. After the update operation, the most recent version of the list is returned as HTML. -
The
DELETE /tasks/{id}
Route: Uses thedeleteTask
handler and a taskId
to delete a specific task. Once the task is deleted, an updated list of tasks is returned back to the client.
And that’s all the routes and handlers used in this application.
You may have noticed some other functions asides the route handlers also defined in our main.go
file. These are functions for performing database operations for fetching tasks (getTasks
), getting a single task using its Id
(getTaskByID
), updating a task using its Id
(updateTaskById
), and deleting a task using the tasks’ Id
(deleTaskWithID
).
These helper functions are used within our route handlers to facilitate database operations and keep the handlers lean.
Creating Our Templates
Now that we are familiar with our Hypermedia API, let’s begin creating the HTML templates that will be retuned in the response to our API calls.
First, we create home.html
file in the templates
folder. This will load the home page of our task management application. Add the following code to the file after creating it.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<title>To Do App</title>
</head>
<body>
<div class="row">
<div class="col">
<h2>Tasks</h2>
<div>
<a href="#" hx-get="/newtaskform" hx-target="#addTaskForm">Add New Item</a>
</div>
<div id="taskList" hx-get="/tasks" hx-trigger="load" hx-swap="innerHTML">
</div>
</div>
<!-- <div class="col">
</div> -->
<div class="col">
<h2>Add New Task</h2>
<div id="addTaskForm">
{{template "addTaskForm"}}
</div>
</div>
</div>
</body>
</html>
This templates forms the shell and layout of the entire application. We have the boilerplate HTML structure and I have also added the Bootstrap CSS library for some basic styling. The HTMX library has also been included through a CDN link.
The application layout contains two sections. One section for displaying tasks and the other for showing the new task and task update forms.
The first section contains a button for requesting a new task form from the hypermedia API. Once the form is returned, we then use hx-target
to load the form into the div
with an id
of addTaskForm
in the forms section of the page.
<a href="#" hx-get="/newtaskform" hx-target="#addTaskForm">Add New Item</a>
The next component in the first section is the div
where our tasks will be loaded into. This div
uses hx-trigger
to initiate a GET
request to the /tasks
route once the page loads, thus immediately loading the tasks into the page.
<div id="taskList" hx-get="/tasks" hx-trigger="load" hx-swap="innerHTML">
</div>
In the second section, as mentioned earlier, we have a div
with an id of addTaskForm
for loading both our new task and update forms. We have also preloaded the form for adding a new task into this div
using Go template import syntax so as to have a default form in place.
Now let’s create the form for adding a new task next. Inside the templates
folder, create the file addTaskForm.html
and add the following code inside it:
{{define "addTaskForm"}}
<form>
<div>
<input type="text" class="form-control" name="task">
</div>
<div class="mt-2">
<button class="btn btn-primary" hx-post="/tasks" hx-target="#taskList" hx-target="innerHTML">
Save
</button>
</div>
</form>
{{end}}
This templates loads a fresh form in the UI for adding a new task. When the submit button is clicked, it uses HTMX to send a POST
request to the /tasks
route to add a new task. When the operation is done, it uses HTMX once again to load the response, an updated list of tasks, into the div
with an id of taskList
.
Next is our update form template. Inside the templates
folder, create the file updateTaskForm.html
and add the following code:
{{define "updateTaskForm"}}
<form>
<div>
<input type="text" class="form-control" name="task" value="{{.Task}}">
</div>
<div>
<input type="checkbox" name="done" {{if .Done}} checked {{end}} id="">
</div>
<div class="mt-2">
<button class="btn btn-primary" hx-put="/tasks/{{.Id}}" hx-target="#taskList" hx-target="innerHTML">
Update Task
</button>
</div>
</form>
{{end}}
This template takes in a task to be updated and uses it to pre-populate the update form so that the user can see the previous state of the task to be updated.
When the Update Task button is clicked, it will send the updated values to the hypermedia API for the task to be updated. Once updated, it loads the updated list into the page.
Finally, we create the template the returns our list of task items. Inside the templates
folder, create the file todoList.html
and add the following code:
{{define "todoList"}}
<ul>
{{range .}}
<li>
<span {{if .Done}} style="text-decoration:line-through" {{end}}>{{.Task}}</span>
[<a href="#" hx-get="/gettaskupdateform/{{.Id}}" hx-target="#addTaskForm" hx-swap="innerHTML">Edit</a>] |
<a href="#" hx-delete="/tasks/{{.Id}}"
hx-confirm="Are you sure you want to Delete this Task?"
hx-target="#taskList">[Delete]</a>
</li>
{{end}}
</ul>
{{end}}
Yeah, a lot is going on in this template, so let’s break it down.
First, the template takes in a Go slice
of Task
types and loops over it using the range
function to create an HTML list of unordered items.
The task it displayed in each list item and the Done
property is used to check if the task is completed. If so, we use CSS to strike the task as being completed.
Just after the task text, we have an Edit button. This button calls the /gettaskupdateform
endpoint to load an update form using the id of the specific task that was clicked. The user can then update the task and get an updated list of task items.
After the Edit button, we have a Delete button that uses hx-delete
to call the DELETE /tasks/{id}
endpoint so that we can delete the task. But before we can send the delete request, we use hx-confirm
to display a confirmation dialog to the user so that they can confirm if they really want to delete this task item. Once deleted, a new updated list is returned and the task will be gone.
And with that we wrap up our application, so let’s move on to the fun part, checking it out.
Running the Application
With all the code in place, now let’s test our application.
Ensure that all files are saved and run the following command at the root of your project:
go run main.go
Now go to your browser and load the application page at http://localhost:4000
. If you have used a different port, ensure that you’re using that port to load the app.
Now you should see your application as displayed below. See below as we add a new task, update an existing task and delete a task from our task list
Conclusion
If you have enjoyed this article, and will like to learn more about building projects with HTMX, I’ll like you to check out HTMX + Go: Build Fullstack Applications with Golang and HTMX, and The Complete HTMX Course: Zero to Pro with HTMX to further expand your knowledge on building hypermedia-driven applications with HTMX.
Happy Coding :)
Top comments (1)
Thanks for series on HTMX and go. Just when I required.