htmx is a new javascript framework that tips the typical front-end development paradigm on its head. A typical webapp these days looks like:
While this has become the defacto model for front-end development in 2022, there are some problems with this approach:
Htmx recognises that:
Instead of returning JSON which is unmarshalled and processed again on the front-end, htmx lets you return already-rendered HTML, which is inserted/update directly on the page. Any element can make requests (GET/POST/PUT/PATCH etc) and there are various ways of triggering those requests (click, polling, typing, websockets).
At this point, if htmx is new to you, you might want to look at their introduction which is probably better at showcasing the benefits than I am.
As an aside, if this sounds “weird”, I don’t blame you. I’ve been in software development for almost 30 years, and this is one of the bigger paradigm shifts I’ve encountered. However, I think it is an approach that makes a lot of sense, and I’m having a lot of fun with it.
For a work project, I needed:
(the last part is crucial to our effective use of htmx)
I found plenty of solutions for parts of these problems, but nothing complete. Additionally, I’m kind of a “from scratch” person, and I like to implement these sorts of things from the ground up - at least initially. It helps provide me with an understanding of the tools and technology which I don’t get if I just bundle someone’s middleware and call it done.
I do make a concession here to frameworks and use gin gonic for our server. It makes the route handlers a little simpler. There is otherwise nothing specific to gin here, and you could adapt these techniques to any other platform.
package main
import (
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/", func(c *gin.Context) {
c.String(200, "hello world")
})
r.Run()
}
If you run this, you should find that you can hit your web server on the default port (8080) and receive a response for requests to “/”. Nothing too earth-shattering yet.
It’s time to start creating our templates and embedding them in our binary at
build time. Create a sub-directory called templates
and create a file
index.html
inside.
<!DOCTYPE html>
<html lang="en">
<body>
<h1>The number one is {{ 1 }}.</h1>
</body>
</html>
You can see that we are including a template directive {{ 1 }}
(that will
return a literal ‘1’), just to prove we are processing this as a template and
not a plain text file.
Now, modify your main.go to bring in the templates and to serve it up:
package main
import (
"embed"
"html/template"
"github.com/gin-gonic/gin"
)
//go:embed templates
var templateFS embed.FS
func main() {
r := gin.Default()
r.GET("/", func(c *gin.Context) {
t := template.Must(template.ParseFS(templateFS, "templates/index.html"))
t.Execute(c.Writer, nil)
})
r.Run()
}
We’ve made a fairly small number of changes, but introduced a few new things, let’s cover them one by one.
//go:embed templates
var templateFS embed.FS
This combination of comment and var declaration use the embed package to bring a virtual filesystem of files into our binary at build time.
The templates
string is a path to a directory (from the point in the
filesystem relative to the package we are building). The embed.FS
implements
fs.FS
so that we can do things like Open("somefile")
to access files, with
our code pulling them directly from our binary.
t := template.Must(template.ParseFS(templateFS, "templates/index.html"))
The inner part (template.ParseFS
) reads from our virtual filesystem (first
argument) a particular file (templates/index.html
) and parses and returns the
parsed template.
The template.Must
wrapper is a common go paradigm, it takes the two returns
from ParseFS
and simply panics if the error is not nil. It is equivalent to:
t, err := template.ParseFS(templateFS, "templates/index.html")
if err != nil {
panic(err)
}
It recognises that errors in these sorts of cases are indicative of programmer error (you added a template with errors in it) and the best you can do is bail out immediately - there is no point in trying to handle this error.
t.Execute(c.Writer, nil)
Our template has been parsed, but not yet executed. The parsing and execution are separate steps, as you can re-use a template struct, passing in different data to render each time.
In this case we don’t have any data we need to send to our template to render
it, so we pass a nil
as the second argument.
The first argument is the io.Writer which is the actual response to our web client.
So - our template gets executed and the {{ 1 }} will get turned into a ‘1’.
Compile and run your program, connect to your web server on http://localhost:8080
and you should see this:
Let’s add some dynamic content, courtesy of htmx.
Add a new template inside the templates directory, called htmx_time.html
:
<div hx-get="/htmx/time.html" hx-trigger="every 2s" hx-swap="outerHTML">
{{ .ts }}
</div>
Now would be a good time to have a look at the excellent
htmx documentation to see how the extra markup works.
But it’s pretty straightforward to understand by reading it - every 2 seconds we
will fetch /htmx/time.html
.
The important part to note is that because of the hx-swap
directive, the fetch
replaces the entire <div>
that it is in. The templated part ({{ .ts }}
)
will be our timestamp, each time we fetch this div we will be making a new
server side request for this fragment which will replace the existing one,
showing a new timestamp and setting us up for another refresh 2 seconds later.
The default for hx-swap
is innerHTML
, which replaces the elements contents,
but not the element itself. You might ask - why not just replace the content,
since the outer <div>
that triggers the reloading will remain the same anyway.
There are certainly situations where you would want to do that. But in our case,
there’s a couple of reasons why we prefer outerHTML
:
hx-
markup, and the reloading stops!So, let’s wire up a route to serve up this template at the right place. Add this after the existing route for “/”:
r.GET("/htmx/time.html", func(c *gin.Context) {
t := template.Must(template.ParseFS(templateFS, "templates/htmx_time.html"))
t.Execute(c.Writer, gin.H{"ts": time.Now().Format(time.Kitchen)})
})
You’ll also need to import the “time” package in your import statement at the
top of main.go
.
One new thing here - when we execute your new template, we are passing some data
in. In this case it is a gin.H
struct (which is just a
map[string]interface
, allowing us to send arbitrary data structure values,
each with a string key). The key is “ts” (matching the .ts
in our
htmx_time.html
template) and the value is a string with the current “kitchen
time” (which is just hours and minutes plus an AM/PM indicator).
If you compile and run this, and fetch http://localhost:8080/htmx/time.html
you should see the current time in your browser. It won’t do anything magical
with respect to refreshing - this is just a HTML fragment at this point.
So let’s change index.html to include the htmx library, and to reference our new fragment:
<!DOCTYPE html>
<html lang="en">
<head>
<script src="https://unpkg.com/htmx.org@1.7.0"></script>
</head>
<body>
<div hx-get="/htmx/time.html" hx-trigger="load" hx-swap="outerHTML">
</div>
</body>
</html>
The <div>
here is slightly different to the one in htmx_time.html - instead of
it reloading on a delay, it will load the new fragment from /htmx/time.html
as
soon as the page loads.
So - after /
loads, htmx will see the hx-get
and hx-trigger
directives,
which will load our fragment via our new route - replacing that div, and
starting the 2 second refresh.
Run your program again and load up http://localhost:8080
- you should see a
page which shows the time, updating every couple of seconds. You can verify this
by just staring at it until the minute ticks over, or check the logs in your
console:
[GIN] 2022/03/03 - 21:10:29 | 200 | 257.250µs | ::1 | GET "/"
[GIN] 2022/03/03 - 21:10:29 | 200 | 210.458µs | ::1 | GET "/htmx/time.html"
[GIN] 2022/03/03 - 21:10:31 | 200 | 196.791µs | ::1 | GET "/htmx/time.html"
[GIN] 2022/03/03 - 21:10:33 | 200 | 554.417µs | ::1 | GET "/htmx/time.html"
[GIN] 2022/03/03 - 21:10:35 | 200 | 498.083µs | ::1 | GET "/htmx/time.html"
You can see it loads the initial index page, then immediately the
/htmx/time.html
fragment. Every 2 seconds after that we refresh the
/htmx/time.html
fragment.
If you saw the almost identical <div>
in the index as the one in
htmx_time.html
and thought it was sub-optimal - you were right. Let’s fix this
right now.
Change index.html
to look like this:
<!DOCTYPE html>
<html lang="en">
<head>
<script src="https://unpkg.com/htmx.org@1.7.0"></script>
</head>
<body>
{{ template "htmx_time.html" .}}
</body>
</html>
Instead of effectively writing our dynamic <div>
twice, we are using the
template
directive to include it inline. Thus our page will have the current
time shown on first load, as well as refreshing every 2 seconds.
However, there are hidden benefits to this technique. Because no javascript is involved on the initial page load, the page will show all of the content, both static and dynamic with the single HTTP request. It will be visible to the end user immediately (without them having to make another HTTP request to get the dynamic content) and it is visible to various “web crawler” robots as well.
This can be a huge win for both page response times and for search engine
relevance. With typical SPA frontend/backend split setups, the page that a
crawler sees is probably just an empty husk, with a bunch of empty <div>
s and
not much else. Additionally, end users no longer need to stare at a bunch of
grey rectangles, waiting for the actual content to come in.
We aren’t quite done yet though. The {{ template }}
directive isn’t “magic” -
it doesn’t know anything about the filesystem, it won’t know how to dynamically
“fetch” the second template when we load index.html
. Let’s make the required
changes to our routes.
Change the two routes to look like this:
r.GET("/", func(c *gin.Context) {
t := template.Must(template.ParseFS(templateFS,
"templates/index.html",
"templates/htmx_time.html"))
t.Execute(c.Writer, gin.H{"ts": timeNow()})
})
r.GET("/htmx/time.html", func(c *gin.Context) {
t := template.Must(template.ParseFS(templateFS,
"templates/htmx_time.html"))
t.Execute(c.Writer, gin.H{"ts": timeNow()})
})
And add the new function we are using now to generate the time string:
func timeNow() string {
return time.Now().Format(time.Kitchen)
}
As usual in Go, there’s little “magic”. If we want our output to contain the
content from two templates, well, we have to specify both those templates in our
ParseFS
call.
When we specify {{ template "htmx_time.html" .}}
in index.html
it looks for
a template within the parsed templates called htmx_time.html
. That name is
automatically derived from the filename when we called ParseFS
here:
t := template.Must(template.ParseFS(templateFS,
"templates/index.html",
"templates/htmx_time.html"))
Our parsed template t
now contains a named sub-template called
htmx_time.html
, which is why we can use it in the
{{ template "htmx_time.html" }}
directive. It’s important to note that the
html/template
directives know nothing about the filesystem - you have to
prepare all you need before you Execute
. I belabour this point a little
because it tripped me up for a while, and because it’s potentially surprising
behaviour if you are used to other languages and frameworks, which provide you
with a more streamlined experience (dynamically loading templates from within
other templates).
This “lack of magic” does mean you have to work harder, but it also (I believe) benefits your understanding, and allows you the ultimate flexibility to make it work in the way that fits your own application.
One other thing to note is the trailing ‘.’ in the directive
{{ template "htmx_time.html" . }}
. It’s important! Passing along the ‘.’
argument (known as the “pipeline” if you see
the docs) means that the struct with the
“ts” key containing our time value is passed along to the other template.
Try it out! Hit http://localhost:8080
in your web browser, and you should
see the time, automatically refreshing. Verify that the inital page load
contained the dynamic content as well by grabbing it with curl
:
$ curl http://localhost:8080/
<!DOCTYPE html>
<html lang="en">
<head>
<script src="https://unpkg.com/htmx.org@1.7.0"></script>
</head>
<body>
<div hx-get="/htmx/time.html" hx-trigger="every 2s">
10:00PM
</div>
</body>
</html>
Have fun with htmx and go templates!