Hacker News

How I use HTMX with Go

Project setup

If you'd like to follow along, go ahead and run the following commands to create a skeleton structure for the project:

That should give you a file tree which looks like this:

Installing HTMX

There are a few different ways to install HTMX, and you could load it from a CDN or install it using NPM, but I almost always download a copy and serve it as a static file from my web application. It's simple and avoids the downsides of using a CDN.

For the purpose of this demo project, we'll also download Bamboo (a classless CSS framework) and an image of a gopher from github.com/egonelbre/gophers. Go ahead and run the following commands to download all three things into the assets/static folder:

The contents of assets/static should now look like this:

The HTML templates

OK, now that the project skeleton and our static assets are in place, let's get to the main thrust of this post and talk about HTML templates.

My starting point in almost all projects is an assets/html directory which has a folder structure like this:

Under this structure:

  • The assets/html/base.tmpl file contains the common HTML 'layout' markup for all web pages.
  • The files in the assets/html/pages directory contain the page-specific content for individual web pages.
  • The files in the assets/html/partials directory contain reusable chunks of HTML markup that can be used in different places.

If you're following along, go ahead and add the following markup to the base.tmpl file:

There are a few things to point out about this:

  • In the <head> section we import the Bamboo CSS file and the HTMX JavaScript file. Note that when importing HTMX we use the defer attribute. This means that HTMX will be fetched by the browser in parallel as it is parsing the web page HTML, but the script won't be executed until the HTML is fully parsed and the DOM is built. There's an excellent blog post which describes how defer works and why it's the right choice here.
  • When writing HTML templates, I like to give all of my templates explicit names by surrounding the markup in {{define}}...{{end}} actions - even if (like in this case) a file only contains one template and it's not strictly necessary. YMMV, but I prefer the consistency and clarity of being able to always refer to templates by defined names from my Go code, rather than using a mixture of defined names and filenames.
  • Within the template, we use actions like {{template "page:title" .}} to inject the appropriate page-specific content in the right place.

Talking of which, let's now add the page-specific content for the homepage to the assets/html/pages/home.tmpl file:

In this page we have a <button> with two HTMX attributes: hx-get="/gopher" and hx-swap="outerHTML". These mean that when this button is clicked, HTMX will intercept the click, send a GET /gopher request to our application, and then replace the button in the DOM with whatever HTML our application sends back.

Lastly, let's add a template to the assets/html/partials/images.tmpl containing some HTML for displaying our downloaded gopher image, like so:

Note that we're using width="{{.}}" in this markup, so that we can pass a dynamic value for the image width to the template.

Embedding the assets

Since file embedding was introduced in Go 1.16, I normally embed HTML files and static assets into a Go binary rather than reading them from disk at runtime.

Let's update the assets/efs.go file to embed the contents of the assets/html and assets/static directories, and make them available in two global variables called HTMLFiles and StaticFiles respectively. Like so:

In this code, the //go:embed "html" "static" directive embeds the contents of the assets/html and assets/static directories into the files variable, which is an embed.FS rooted in the assets directory. I've then used a small sub() function to create two sub-filesystems with their roots in the html and static directories, and assigned them to the HTMLFiles and StaticFiles variables respectively.

Doing this has two benefits:

  • It provides a clear separation between the static and HTML files when we are using them from our Go code. Code that is intended to only work with our static files won't have unnecessary access to our HTML files, and vice-versa.
  • Code using the HTMLFiles and StaticFiles filesystems doesn't need to include the html/ or static/ path prefix when opening files.

HTML template rendering

For rendering the HTML templates in an HTTP response, I've found that a nice pattern is to create a htmlRenderer type which a) parses a set of shared templates at startup; b) has a render() method that clones and extends the shared template set, before executing a specific named template and sending it as an HTTP response.

Go ahead and create the htmlRenderer type in the cmd/web/html.go file like so:

And then in the cmd/web/main.go file, let's create a basic web application like so:

The important and relevant thing for this post is the initialization call to newHTMLRenderer(). In this call we pass in the glob paths "base.tmpl" and "partials/*.tmpl", which means that the base template and all templates in the partials directory will be available in the shared template set.

And with that in place, we can then write the code for the home handler in cmd/web/handlers.go like so:

When we call render() in the code above, we are effectively saying "append the templates in pages/home.tmpl to the shared template set, and then render the base template along with a 200 OK status."

At this point, you should be able to successfully run the application:

And if you visit http://localhost:5051 in your browser, you should see the homepage displayed like so:

Rendering partials

While you're on this homepage, if you open developer tools and then click the "Wanna see a cute gopher?" button, you'll see that it sends a GET /gopher request that 404s.

Let's fix this so that our application includes a GET /gopher route, which returns the contents of the partial:image:gopher template.

First add the new route like so:

And then in cmd/web/handlers.go create a new gopher() handler, which renders the partial:image:gopher template with a width of 100px. Because we've set up our htmlRenderer type so that the shared template set already includes all partials, it's sufficient for us to call render() like this without passing in any additional file paths.

If you re-run the application now and click the button, you should see that it gets swapped out for a gopher image like so:

So, it's taken a while to get here, but the pattern that we now have in place is neat and has some nice benefits.

  • Our templates (and static assets) are embedded into the Go binary, which makes for easy distribution and deployment.
  • We can use the same htmlRenderer.render() function to send either complete HTML pages or specific partials to the client, which makes it easy to send back partial responses when they are needed by HTMX.
  • We can keep the HTML markup nice and DRY by using the base template and partials. The partials can be inserted in the base template, page-specific content, or even in other partials.

A more complex example

That was very basic in terms of interactivity, so let's do something a bit more realistic and create a 'user search' page that mimics the active search example from the HTMX website.

To make this work, we'll create two new routes in our application:

  • A GET /users route which returns a full HTML page containing a table of all user details.
  • A GET /users/search route which returns an HTML partial containing table rows only for users whose names or emails match a specific search value.

Now that we've got all the groundwork in place, it should be pretty quick to do.

Let's first add an assets/html/pages/users.tmpl file with the page-specific HTML content:

There are a couple of interesting things here. The first is the HTMX attributes on the <input> control. We've configured this so that when a user types into the input, after a delay of 500ms (or immediately if they press Enter), HTMX will send a request containing the search term as a query string like GET /users/search?query=foo. When a response is received, HTMX will then swap the response into the inner HTML of the <tbody> element.

For demonstration purposes in this project, we're also using the hx-push-url="true" attribute, which will result in the browser URL bar being updated and a new entry added to the browser history each time HTMX makes a request.

I've also structured the file so that the table rows are rendered in their own users:rows template, rather than as part of the page:content template. We'll use this in the GET /users/search to render just the matching user table rows for HTMX to swap in.

Then let's set up the two new routes in main.go:

And lastly let's go to the handlers.go file and create a hardcoded list of user details, along with the two new handlers listUsers and searchUsers, like so:

When it comes to template rendering, in both of these new handlers we are adding the templates from the pages/users.tmpl file to the shared template set, but in listUsers we execute the base template and in searchUsers we execute just the users:rows template.

So with just a little bit of thought to how we structured the markup and defined the templates in the pages/users.tmpl file, it's straightforward for us to send back either a complete HTML document or the appropriate partial HTML fragment for HTMX to do its thing.

If you want, try this out by visiting http://localhost:5051/users and you should see the list being filtered as you type.

Checking if a request is coming from HTMX

This all works well, but what if someone visits a link like http://localhost:5051/users/search?query=leo directly? Or shares a link to it? Anyone visiting this directly would only see the partial HTML response in their browser, similar to this:

This obviously isn't ideal. A much better approach would be to change the response that our searchUsers handler sends, depending on whether the request is coming from HTMX or not. Specifically:

  • If the request is coming from HTMX, we should return an HTML partial that it can swap into the table, just like we already are.
  • If the request is not coming from HTMX, we should return a full HTML page that contains the matching user details.

As you may already know if you've used HTMX before, requests that come from HTMX always include an HX-Request: true header. So all we need to do is check for the presence of that in the request, and send back the appropriate response.

To help with this, I normally create a little isHTMXRequest() function and use it like so:

If you restart the application and visit http://localhost:5051/users/search?query=leo again now, you should see a full HTML page containing only the matching user records.

But there are a couple more things we need to do to finish this up.

Setting the Vary header

Because we're sending back different responses from searchUsers based on the value of the HX-Request header, we should also set a Vary: HX-Request on the response to tell any caches between our server and the client that responses may be different based on the value of this header.

We could set the Vary: HX-Request header in searchUsers, but I think it's easier to just always set it on all responses in the render() function. It does mean that we'll be setting the Vary header on all responses - including those from our home handler and listUsers - which isn't strictly necessary and a little bit wasteful. But I think it's worth it to avoid having to remember setting the Vary header correctly in individual handlers, and the risk of bugs that forgetting it may cause.

Back-button behavior

Lastly, we need to consider back-button behavior. Whenever HTMX adds an entry to the browser history (which it will do when you use the hx-push-url or hx-boost attributes), it caches the HTML for the complete page in the browser's local storage. When the user clicks the back button, this complete cached HTML page will be reshown to them.

By default the HTMX cache stores up to 10 pages. If there is a cache miss (i.e. the user navigates back far enough that there is no longer a matching page in the cache), HTMX will resend the request to the server to refetch the content for that URL. The problem is that this request will include the HX-Request: true header, and our application will send back a partial HTML response rather than the complete HTML page that it needs to redisplay to the user.

To deal with this scenario, there is an historyRestoreAsHxRequest setting which controls whether HTMX will include the HX-Request: true header when it's sending a request because of a cache miss. The documentation advises: "This should

Comments

No comments yet. Start the discussion.