This page looks best with JavaScript enabled

Learn Go and HTMX with a Simple Book Tracker

 ·   ·  ☕ 9 min read

I have not been a fan of server-driven frontend experiences, but HTMX renaissance has piqued my interest. And yes, that only gets amplified with BunJS claims of astronomical speed for server and what it means for my choice of technologies moving forward. That is for a future post, but here we explore how Golang and HTMX can work together to create a “SPA-like” experience.

Features that are of interest here -

  1. Go and Go templates
  2. How to get HTMX working with Go templates
  3. How will static files co-exist with the dynamic behavior of HTMX (well, this is just me)

What is not considered -

  1. Authentication / user handling and all the good stuff
  2. Database and persistence of books

Install

Download Go and click on button and tab to install Go on your machine.

HTMX needs no install. We will just reference it with a CDN in the HTML.

Install nodemon globally since Go’s go run does not watch for changes.

1
npm install -g nodemon

If you are stuck anywhere while writing code, refer to the repository on Github.

Create a New Project

Create a new project folder and initialize a Go module.

1
2
3
mkdir go-htmx-playground
cd go-htmx-playground
go mod init example.com/go-htmx-playground

Your go.mod file should look like this:

1
2
3
module example.com/go-htmx-playground
go 1.21
// or whatever the version is 

Create a simple server with a new file called main.go.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package main
import (
	"log"
	"net/http"
  "io"
)

func main() {
	log.Println("Starting server at http://localhost:8080")
  
  http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
	  log.Println("hello");
    io.WriteString(w, "Hello, World")
 	})

  log.Fatal(http.ListenAndServe(":8080", nil))
}

You may have noted that you don’t particularly need to type in the import.

Run the server with go run main.go and visit http://localhost:8080/hello to see the output.

It is time to reload server automatically when any changes are made to files.

1
2
# install nodemon globally
nodemon --exec go run main.go

Create a nodemon.json file to specify which folders need to be watched.

1
2
3
4
5
6
7
8
{
  "verbose": true,
  "execMap": {
    "go": "go",
    "html": "html"
  },
  "ignore": ["node_modules/*"]
}

If you are into auto formatting using prettier and I don’t see why you shouldn’t, add prettier to work smoothly with Go and Go templates:

1
npm i prettier prettier-plugin-go-template --save-dev

Modularizing the Code and Adding HTML

We don’t want to stuff everything in main.go. Let us create a folder pages and serve all pages from there.

But before creating the HTML page, we want to make sure things work.

Create a new file pages/hello.go and add the following code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package pages

import (
	"io"
	"net/http"
)

func HelloHandler(w http.ResponseWriter, r *http.Request) {
	io.WriteString(w, "Hello, World")
}

Update main.go to use the new handler.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

import (
  "io"
	"net/http"
  P "example.com/go-htmx-playground/pages"
)

func main() {
	log.Println("Starting server at http://localhost:8080")

	http.HandleFunc("/hello", P.HelloHandler)
  log.Fatal(http.ListenAndServe(":8080", nil))
}

This works as expected. Now, let us create a new file pages/hello.html and add the following code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <link rel="stylesheet" href="https://unpkg.com/chota@latest" />
  <link rel="stylesheet" href="/static/css/main.css" />
  <title>Booksie</title>
</head>
<body>
  <nav class="nav">
    <div class="nav-center">
      <a class="brand" href="#">Booksie</a>
      <div class="tabs">
        <a class="active">Home</a>
        <a href="/help">Help</a>
      </div>
    </div>
  </nav>
  <main class="container">
    <h2>My Books</h2>

    <div class="row">
      <div class="col-12 card bg-light">
        <form>
          <div class="col-12 col-6-md ">
            <label for="title-id">Title</label>
            <input type="text" name="title" id="title-id" />
          </div>

          <div class="col-12 col-6-md ">
            <label for="author-id">Author</label>
            <input type="text" name="author" id="author-id" />
          </div>

          <div class="col-12 is-right">
            <button type="submit" class="button primary">Add</button>
          </div>
        </form>
      </div>
    </div>

    <div class="col-12">
      <!-- Sample books -->
      <div class="row">

      <div class="col-12">
        <h4>The Great Gatsby</h4>
      </div>

      <div class="col-12 col-6-md">
        🖋️ F. Scott Fitzgerald <br>
      </div>
    </div>
    </div>
  </main>
</body>
</html>

You should now see the glorious HTML page with a form and a sample book.

htmx-go-book-tracker-app

You will notice a couple of things off the bat:

  1. chota.css for styling. It is a tiny CSS framework that I like to use for simple projects and demos. You can use any CSS framework of your choice.
  2. Custom CSS referenced in the html page does not exist, we will get to that in a bit.
  3. The form does not do anything. We will get to that in a bit as well.

Serving Static Assets

We need to serve static files like CSS, JS, and images. Create a new folder static and add a new file static/css/main.css with the following code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: bold;
  font-size: 0.9rem;
  color: var(--color-grey);
  letter-spacing: 0.1em;
}

h2 {
  margin-top: 2em;
}

Add the below line to main.go.

1
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))

The http.FileServer part is evident. http.StripPrefix(...) is needed to remove the /static/ prefix from the URL since -

  1. Go http will see static as a directory and try to serve it
  2. /static/ will get directed to a route ../static which does not exist

Production sites often have static files or ready content to serve. We will take an example of help page to demonstrate. Create a new file pages/help.html and add any sample content.

1
  http.Handle("/help/", http.StripPrefix("/help/", http.FileServer(http.Dir("./help"))))

Using Go Templates

With Go templates we separate the content from the presentation.

Add below code to main.go -

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type Book struct {
	Title  string
	Author string
}


http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		log.Println("/ request received")
		path := "pages/index.html"

		if r.URL.Path != "/" {
			path = "pages" + strings.TrimSuffix(r.URL.Path, "/") + ".html"
		}
		books := map[string][]Book{
			"Books": {
				{Title: "The Great Gatsby", Author: "F. Scott Fitzgerald"},
				{Title: "To Kill a Mockingbird", Author: "Harper Lee"},
				{Title: "1984", Author: "George Orwell"},
			},
		}
		tmpl := template.Must(template.ParseFiles(path))
		tmpl.Execute(w, books)
	})

books is a just a map of strings to slice of Book struct. We will refer Books within the map in html to render the books in the HTML page. In real world, books will come from a database query.

Replace the hard-coded books in index.html to -

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
 <div class="col-12">
  <div class="row" id="books-list">
    {{ range .Books}}
    
    <div class="col-12 col-6-md card">
      <div class="row">

        <div class="col-12">
          <h4>{{ .Title }} </h4>
        </div>

        <div class="col-12 col-6-md">
          🖋️ {{ .Author }} <br>


        </div>
      </div>
    </div>
    
    {{ end }}
  </div>
</div>

You will now see books on the site, but the information is coming from the Go server.
All of this is good but it provides a fairly static experience. Importantly, we have not added any functionality to add books.

Enter HTMX.

Add HTMX

Add the following script to the bottom of the head tag in index.html.

1
<script src="https://unpkg.com/htmx.org/dist/htmx.js"></script>

Modify the form to add hx-post and hx-target attributes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<form hx-post="book-add" hx-target="#books-list" hx-swap="beforeend"
  hx-on::after-request=" if(event.detail.successful) this.reset()">
  <div class="row">
    <div class="col-12 col-6-md ">
      <label for="title-id">Title</label>
      <input type="text" name="title" id="title-id" />
    </div>

    <div class="col-12 col-6-md ">
      <label for="author-id">Author</label>
      <input type="text" name="author" id="author-id" />
    </div>
    <div class="col-12 is-right">
      <button type="submit" class="button primary">Add</button>
    </div>
  </div>
</form>

You will see that -

  1. hx-post="book-add" is meant to call the /book-add route in the Go server
  2. hx-target="#books-list" will update the #books-list element with the response from the server. The response is in the form of HTML and will be added to the #books-list element.
  3. hx-swap="beforeend" will add the response to the end of the #books-list element. We are not replacing the entire book list with the server response, rather just add the new book at the very end of the list. See more on these methods in HTMX docs
  4. We reset form on successful submission with hx-on::after-request=" if(event.detail.successful) this.reset()

Add a template reference in the book data rendering code -

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<div class="row" id="books-list">
  {{ range .Books}}
  {{ block "book-item" . }}
  <div class="col-12 col-6-md card">
    <div class="row">

      <div class="col-12">
        <h4>{{ .Title }} </h4>
      </div>

      <div class="col-12 col-6-md">
        🖋️ {{ .Author }} <br>


      </div>
    </div>
  </div>
  {{ end }}
  {{ end }}
</div>

The only change you see is the {{ block "book-item" . }} reference. We have used block to create a template reference. Go code will refer to this template reference so that you can just pass in the data and rest of the HTML + CSS will be reused from the HTML file. There are of course more mature ways of handling this in real-world apps including creation of separate template files for individual components.

Add the following code to main.go to handle the book-add request.

1
2
3
4
5
6
7
8
http.HandleFunc("/book-add", func(w http.ResponseWriter, r *http.Request) {
  title := r.PostFormValue("title")
  author := r.PostFormValue("author")
  log.Println("html request received.. " + title + " " + author)

  tmpl := template.Must(template.ParseFiles("pages/index.html"))
  tmpl.ExecuteTemplate(w, "book-item", Book{Title: title, Author: author})
})

Here -

  1. We just receive the post request
  2. Parse and update the content

We would have written to a database in real-world apps.

That is it. You have a working app with HTMX and Go, which does not need any refreshes and behaves like a single page app.

See the complete code is on Github.

Conclusion

  • The simplicity of htmx is awesome. It is a great way to add interactivity to the page without having to deal with JavaScript.
  • HTMX will reduce a lot of code that we have all come to write and love(?)
  • Javascript will not go anywhere. I find it more intuitive to write JS than Go templates + HTMX (or any other template engine for that matter).
  • If you take the Javascript part out of the equation, writing future web apps in languages like Go, C#, etc. may in fact be enjoyable.

While I continue to not be a fan of server-driven frontend and templates (like Go templates, Pug), I am excited to see how htmx will evolve and what it means for the future of frontend development.

Stay in touch!
Share on

Prashanth Krishnamurthy
WRITTEN BY
Prashanth Krishnamurthy
Technologist | Creator of Things