Skip to content
gohacks.dev
Web

Build a Server Rendered Dashboard in Go With HTMX Basics

Build a dashboard in Go with html/template and HTMX: fragments chosen by the HX-Request header, polling cards, filters, a retry button and HX-Trigger.

16 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

A dashboard is what people reach for a JavaScript framework to build: numbers that refresh, a table that filters, buttons that act without reloading the page. HTMX takes a different route. The server keeps rendering HTML, and a small script in the browser lets any element make a request and swap the response into the page. There is no client-side state to synchronise with the server's, no JSON API to design alongside the pages, and no build step. For internal tools and admin screens, where the interface is a view over server data, it is hard to beat for the amount of code it takes.

Tested with Go 1.25.6 with htmx 2.x attributes, macOS on Apple Silicon (arm64)

This project builds a small operations dashboard in Go with html/template and HTMX attributes, and tests the server side with plain httptest, since the interesting contract is what the server sends for which request. It belongs to the web cluster that ends in a production HTTP server, uses html/template and template layouts, and its typed-component sibling is templ with HTMX. The attributes used follow the HTMX documentation, and the header contract is described in its reference. Everything ran on Go 1.25.6; the browser side is a single script tag.

The idea in four attributes

HTMX adds behavior through attributes, and four of them cover most of a dashboard.

  • hx-get and hx-post make an element issue a request when it is clicked or submitted, to the URL given.
  • hx-target names the element that will receive the response; by default it is the element itself.
  • hx-swap says how: replace its inner HTML (the default), replace the whole element (outerHTML), append to it and so on.
  • hx-trigger says when: a click by default, but also every 5s for polling, load for fetching as the page appears, or keyup changed delay:300ms for a search box.

The server's side of the bargain is to return a fragment, not a page. A response to hx-get="/fragments/stats" is the <div> with the numbers, nothing around it. This is why the design question for the server is how to produce both a full page and the pieces of it without writing everything twice.

One set of templates, two kinds of response

The answer is to give each piece of the page a name. The full page is a layout that includes the named pieces, and a fragment endpoint renders one piece. A helper decides which to send by looking at the request.

dash.goGo
package main

import (
	"fmt"
	"html/template"
	"net/http"
	"strings"
	"sync"
	"time"
)

type Job struct {
	ID       int
	Name     string
	Status   string
	Duration time.Duration
}

type Stats struct {
	Total, Failed, Running int
	FailRate               string
}

type Dash struct {
	mu   sync.Mutex
	jobs []Job
	tick int
}

func NewDash() *Dash {
	return &Dash{jobs: []Job{
		{1, "nightly-backup", "done", 41 * time.Second},
		{2, "send-invoices", "failed", 3 * time.Second},
		{3, "reindex-search", "running", 0},
		{4, "rotate-logs", "done", 2 * time.Second},
		{5, "sync-crm", "failed", 12 * time.Second},
		{6, "<script>alert(1)</script>", "queued", 0},
	}}
}

func (d *Dash) stats() Stats {
	d.mu.Lock()
	defer d.mu.Unlock()
	s := Stats{Total: len(d.jobs)}
	for _, j := range d.jobs {
		switch j.Status {
		case "failed":
			s.Failed++
		case "running":
			s.Running++
		}
	}
	s.FailRate = fmt.Sprintf("%.0f%%", 100*float64(s.Failed)/float64(max(s.Total, 1)))
	return s
}

func (d *Dash) list(status string) []Job {
	d.mu.Lock()
	defer d.mu.Unlock()
	var out []Job
	for _, j := range d.jobs {
		if status == "" || j.Status == status {
			out = append(out, j)
		}
	}
	return out
}

var validStatus = map[string]bool{"": true, "queued": true, "running": true, "failed": true, "done": true}

const tmplText = `
{{define "stats"}}<section id="stats" hx-get="/fragments/stats" hx-trigger="every 5s" hx-swap="outerHTML">
<p>{{.Total}} jobs, {{.Running}} running, {{.Failed}} failed ({{.FailRate}})</p></section>{{end}}

{{define "row"}}<tr id="job-{{.ID}}"><td>{{.Name}}</td><td>{{.Status}}</td><td>{{.Duration}}</td>
<td>{{if eq .Status "failed"}}<button hx-post="/jobs/{{.ID}}/retry" hx-target="#job-{{.ID}}" hx-swap="outerHTML">Retry</button>{{end}}</td></tr>{{end}}

{{define "table"}}<table id="jobs"><thead><tr><th>Job</th><th>Status</th><th>Took</th><th></th></tr></thead>
<tbody>{{range .}}{{template "row" .}}{{else}}<tr><td colspan="4">No jobs match.</td></tr>{{end}}</tbody></table>{{end}}

{{define "page"}}<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Jobs</title>
<script src="/static/htmx.min.js" defer></script></head><body>
<h1>Jobs</h1>
{{template "stats" .Stats}}
<nav>{{range .Filters}}<a href="/?status={{.}}" hx-get="/jobs?status={{.}}" hx-target="#jobs" hx-swap="outerHTML" hx-push-url="/?status={{.}}">{{if .}}{{.}}{{else}}all{{end}}</a> {{end}}</nav>
{{template "table" .Jobs}}
<div id="toast" aria-live="polite"></div>
</body></html>{{end}}`

var tmpl = template.Must(template.New("dash").Parse(tmplText))

// isHX reports whether the browser asked for a fragment. A boosted request is a full navigation done with fetch.
func isHX(r *http.Request) bool {
	return r.Header.Get("HX-Request") == "true" && r.Header.Get("HX-Boosted") != "true"
}

func (d *Dash) Routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", d.index)
	mux.HandleFunc("GET /jobs", d.jobsList)
	mux.HandleFunc("GET /fragments/stats", d.statsFragment)
	mux.HandleFunc("POST /jobs/{id}/retry", d.retry)
	return vary(mux)
}

// vary tells caches that the same URL gives different bodies with and without the HTMX header.
func vary(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Add("Vary", "HX-Request")
		next.ServeHTTP(w, r)
	})
}

func (d *Dash) page(w http.ResponseWriter, status string) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	tmpl.ExecuteTemplate(w, "page", map[string]any{
		"Stats": d.stats(), "Jobs": d.list(status), "Filters": []string{"", "queued", "running", "failed", "done"},
	})
}

func (d *Dash) index(w http.ResponseWriter, r *http.Request) {
	status := r.URL.Query().Get("status")
	if !validStatus[status] {
		http.Error(w, "unknown status", http.StatusBadRequest)
		return
	}
	d.page(w, status)
}

func (d *Dash) jobsList(w http.ResponseWriter, r *http.Request) {
	status := r.URL.Query().Get("status")
	if !validStatus[status] {
		http.Error(w, "unknown status", http.StatusBadRequest)
		return
	}
	if !isHX(r) {
		d.page(w, status) // opened directly, or JavaScript is off: the same URL still works
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	tmpl.ExecuteTemplate(w, "table", d.list(status))
}

func (d *Dash) statsFragment(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	tmpl.ExecuteTemplate(w, "stats", d.stats())
}

func (d *Dash) retry(w http.ResponseWriter, r *http.Request) {
	var id int
	if _, err := fmt.Sscanf(r.PathValue("id"), "%d", &id); err != nil {
		http.NotFound(w, r)
		return
	}
	d.mu.Lock()
	var found *Job
	for i := range d.jobs {
		if d.jobs[i].ID == id && d.jobs[i].Status == "failed" {
			d.jobs[i].Status, d.jobs[i].Duration = "queued", 0
			found = &d.jobs[i]
		}
	}
	var row Job
	if found != nil {
		row = *found
	}
	d.mu.Unlock()
	if found == nil {
		http.Error(w, "only failed jobs can be retried", http.StatusConflict)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("HX-Trigger", `{"toast":"`+strings.ReplaceAll(fmt.Sprintf("Job %d queued again", id), `"`, "")+`"}`)
	tmpl.ExecuteTemplate(w, "row", row)
}

The template block carries the structure. stats, row and table are fragments; page is the layout that includes them. The stats card has hx-trigger="every 5s" and hx-swap="outerHTML", so it polls its own endpoint every five seconds and replaces itself with whatever comes back, which is the same stats template, so the polled version and the first-load version cannot drift apart. The filter links are ordinary anchors with an href that works on its own and an added hx-get that fetches only the table and an hx-push-url that keeps the address bar honest. A visitor without JavaScript clicks the link and gets a full page. That is the property that makes this an enhancement and not a dependency.

The isHX helper is the switch. HTMX sends HX-Request: true on its requests; a full page request does not have it. A request that HTMX marks as boosted (HX-Boosted, from hx-boost on a link) is a whole-page navigation done through fetch, which should get the whole page, so the helper excludes it. And vary adds Vary: HX-Request to every response, because the same URL now returns different bodies depending on a request header. Without it, a CDN or a browser cache that stores the fragment for /jobs?status=failed can serve it to someone who navigated there directly, and they see a bare table with no page around it.

Seeing what the server sends

main.goGo
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
)

func main() {
	srv := httptest.NewServer(NewDash().Routes())
	defer srv.Close()

	do := func(method, path string, hx bool) (*http.Response, string) {
		req, _ := http.NewRequest(method, srv.URL+path, nil)
		if hx {
			req.Header.Set("HX-Request", "true")
		}
		res, _ := http.DefaultClient.Do(req)
		defer res.Body.Close()
		b, _ := io.ReadAll(res.Body)
		return res, string(b)
	}
	rows := func(s string) int { return strings.Count(s, "<tr id=") }
	full := func(s string) bool { return strings.Contains(s, "<!doctype html>") }

	res, body := do("GET", "/", false)
	fmt.Printf("GET /                          full page=%v rows=%d, %d bytes, vary=%q\n", full(body), rows(body), len(body), res.Header.Get("Vary"))
	_, body = do("GET", "/jobs?status=failed", true)
	fmt.Printf("GET /jobs?status=failed (HX)   full page=%v rows=%d, %d bytes\n", full(body), rows(body), len(body))
	_, body = do("GET", "/jobs?status=failed", false)
	fmt.Printf("GET /jobs?status=failed        full page=%v rows=%d  (same URL, no JavaScript)\n", full(body), rows(body))
	res, body = do("GET", "/jobs?status=nonsense", true)
	fmt.Printf("GET /jobs?status=nonsense      %d %s\n", res.StatusCode, strings.TrimSpace(body))
	_, body = do("GET", "/fragments/stats", true)
	fmt.Println("stats fragment:", strings.TrimSpace(strings.ReplaceAll(body, "\n", " ")))

	res, body = do("POST", "/jobs/2/retry", true)
	fmt.Printf("POST /jobs/2/retry             %d  HX-Trigger=%s  row has a retry button=%v\n", res.StatusCode, res.Header.Get("HX-Trigger"), strings.Contains(body, "Retry"))
	_, body = do("GET", "/fragments/stats", true)
	fmt.Println("stats after the retry:", strings.TrimSpace(strings.ReplaceAll(body, "\n", " ")))
	res, _ = do("POST", "/jobs/2/retry", true)
	fmt.Println("retry again (no longer failed):", res.StatusCode)
	res, _ = do("POST", "/jobs/abc/retry", true)
	fmt.Println("retry a bad id:                ", res.StatusCode)

	_, body = do("GET", "/", false)
	fmt.Println("hostile job name is escaped:", strings.Contains(body, "&lt;script&gt;"), "| raw tag present:", strings.Contains(body, "<script>alert"))
}
Text
GET /                          full page=true rows=6, 1780 bytes, vary="HX-Request"
GET /jobs?status=failed (HX)   full page=false rows=2, 436 bytes
GET /jobs?status=failed        full page=true rows=2  (same URL, no JavaScript)
GET /jobs?status=nonsense      400 unknown status
stats fragment: <section id="stats" hx-get="/fragments/stats" hx-trigger="every 5s" hx-swap="outerHTML"> <p>6 jobs, 1 running, 2 failed (33%)</p></section>
POST /jobs/2/retry             200  HX-Trigger={"toast":"Job 2 queued again"}  row has a retry button=false
stats after the retry: <section id="stats" hx-get="/fragments/stats" hx-trigger="every 5s" hx-swap="outerHTML"> <p>6 jobs, 1 running, 1 failed (17%)</p></section>
retry again (no longer failed): 409
retry a bad id:                 404
hostile job name is escaped: true | raw tag present: false

The first rows of output are the contract. The full page has all six rows and the Vary header. The same filter URL returns only the table for an HTMX request, with the two failed rows, and the full page when requested without the header: that is progressive enhancement, checkable from a test. A bad filter value is a 400 with a plain message in both cases. The stats fragment is a few words of HTML.

The retry shows the action pattern. POST /jobs/2/retry returns the new state of the one row (no retry button any more, since the job is queued), and its HX-Trigger header carries a small JSON object that HTMX turns into a browser event named toast; a line of script in the page can listen for it and show the message. The following stats fragment reflects the change. A second retry on the same job is a 409 Conflict, and a malformed ID is a 404. The last line shows the template's escaping at work: a job named <script>alert(1)</script> is displayed as text.

Tests for the contract

The things worth pinning are not pixels but the rules: which requests get a fragment, that every route varies on the header, that unknown input is rejected, and that hostile text stays text. They run as ordinary Go tests against the handler.

dash_test.goGo
package main

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func get(h http.Handler, method, path string, headers ...string) *httptest.ResponseRecorder {
	req := httptest.NewRequest(method, path, nil)
	for i := 0; i+1 < len(headers); i += 2 {
		req.Header.Set(headers[i], headers[i+1])
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec
}

func TestFragmentsAndPagesShareTheSameRows(t *testing.T) {
	h := NewDash().Routes()
	frag := get(h, "GET", "/jobs?status=failed", "HX-Request", "true")
	page := get(h, "GET", "/jobs?status=failed")
	if strings.Contains(frag.Body.String(), "<html") || !strings.Contains(page.Body.String(), "<html") {
		t.Fatal("an HTMX request must get a fragment and a plain request the whole page")
	}
	if strings.Count(frag.Body.String(), "<tr id=") != strings.Count(page.Body.String(), "<tr id=") {
		t.Error("the fragment and the page show different rows")
	}
}

func TestBoostedNavigationGetsTheWholePage(t *testing.T) {
	rec := get(NewDash().Routes(), "GET", "/jobs?status=done", "HX-Request", "true", "HX-Boosted", "true")
	if !strings.Contains(rec.Body.String(), "<html") {
		t.Error("a boosted request replaces the page body and needs the full document")
	}
}

func TestEveryResponseVariesOnTheHTMXHeader(t *testing.T) {
	h := NewDash().Routes()
	for _, path := range []string{"/", "/jobs", "/fragments/stats", "/jobs?status=bogus"} {
		if v := get(h, "GET", path).Header().Values("Vary"); !strings.Contains(strings.Join(v, ","), "HX-Request") {
			t.Errorf("%s: Vary = %v; a cache could serve a fragment as a page", path, v)
		}
	}
}

func TestRetryChangesStateOnce(t *testing.T) {
	h := NewDash().Routes()
	first := get(h, "POST", "/jobs/2/retry", "HX-Request", "true")
	second := get(h, "POST", "/jobs/2/retry", "HX-Request", "true")
	if first.Code != 200 || second.Code != http.StatusConflict {
		t.Fatalf("statuses %d then %d, want 200 then 409", first.Code, second.Code)
	}
	if !strings.Contains(first.Header().Get("HX-Trigger"), "toast") {
		t.Error("the first retry should tell the page to show a message")
	}
}
Text
=== RUN   TestFragmentsAndPagesShareTheSameRows
--- PASS: TestFragmentsAndPagesShareTheSameRows (0.00s)
=== RUN   TestBoostedNavigationGetsTheWholePage
--- PASS: TestBoostedNavigationGetsTheWholePage (0.00s)
=== RUN   TestEveryResponseVariesOnTheHTMXHeader
--- PASS: TestEveryResponseVariesOnTheHTMXHeader (0.00s)
=== RUN   TestRetryChangesStateOnce
--- PASS: TestRetryChangesStateOnce (0.00s)
PASS
ok  	example.test/lab	1.326s

The third test is the one most often missing from real HTMX projects, and the one whose absence causes the strangest bugs: with a CDN in front, a fragment cached under a page URL appears as a naked table when someone opens the link in a new tab. The fourth makes the retry idempotent in effect: a double click cannot queue the job twice because the second request finds it already queued and gets a conflict.

Fragments are endpoints, and endpoints need guards

A point that surprises people new to HTMX: every fragment is a URL, reachable by anyone who can guess it, with no page around it to hide it. /fragments/stats and /jobs/2/retry must pass through the same authentication and authorisation middleware as the pages (auth middleware). It is easy to protect the page route and forget that the fragment behind it returns the same data. The same goes for CSRF on every non-GET route, rate limits on the polled endpoints, and input validation, as the status filter shows: the value goes into a Go map lookup, never into a query string.

Keep the actions precise. A POST that changes state should check the current state (the retry refuses a job that is not failed) rather than trusting what the page displayed when the button was rendered, because the page may be minutes old, or the request may come from a different tab or from curl. The server is always the authority, and a fragment is only a rendering of its answer.

Listening for what the server says

HX-Trigger fires a browser event, so a few lines of script turn the header into a visible message. This is the only JavaScript the page needs of its own:

Text
document.body.addEventListener("toast", (e) => {
  const el = document.getElementById("toast");
  el.textContent = e.detail.value;
  setTimeout(() => (el.textContent = ""), 4000);
});

The toast element has aria-live="polite" in the layout, so a screen reader announces the message without moving focus. That attribute is worth a note on accessibility generally. When HTMX replaces content, assistive technology does not know it happened unless you tell it: mark regions that update on their own with aria-live, keep focus where the user left it (HTMX preserves focus on elements with a stable id across a swap, which is another reason the rows carry id="job-N"), and make sure every action is a real button or a, not a div with a click handler.

What you give up, and what you do not need

Compared with a JSON API and a single-page client, this design has no client-side router, no store and no duplicate validation. The server renders what it knows, and the page is a direct view of it. You also do not design and version a JSON contract between two codebases, because the contract is the HTML. What you give up is local interactivity: every change that depends on data costs a round trip, and a flaky connection is felt immediately. For internal tools, where users are on good networks and the screens are views over server data, that trade is excellent. For anything that must feel instant offline, it is not, and the honest answer is a client framework for that part and server-rendered pages for the rest, which the two approaches coexist fine to do.

Things to get right in real use

CSRF. An hx-post is a cookie-authenticated POST like any form. Protect it as in the CSRF article: put the token in a meta tag and add it to every HTMX request with hx-headers on the body, or send it as a hidden field. SameSite cookies are a good second layer.

Error responses. By default HTMX does not swap 4xx and 5xx responses into the page, which makes failures invisible. Listen for the htmx:responseError event and show a message, or configure which statuses swap. Return friendly fragments for expected errors such as the 409 above.

Order of events and polling. A poll every five seconds is cheap for one dashboard and expensive for a thousand. Keep the fragment endpoint fast (a few indexed queries, no per-request rendering of anything big), add Cache-Control: no-store, and consider server sent events from the SSE article when updates are rare and polling is mostly wasted. HTMX has an SSE extension for exactly this.

Out-of-band swaps. When one action must update two places, such as the row and the stats card, the response can include the extra fragment marked hx-swap-oob="true", and HTMX places it by id. That keeps the server in charge of consistency without a client-side store.

Self-host the script. Serve htmx.min.js from your own origin (an embedded file, with a long cache lifetime and a fingerprinted name as in cache headers), pin the version, and keep your Content-Security-Policy free of third-party script sources.

Rolling it out in an existing app

You do not have to rebuild a server-rendered application to adopt HTMX. Pick the one screen that hurts most, usually a long list with filters or a form with a slow validation step, and enhance it: add hx-get to the filter links, split the table into a named template, return it for HTMX requests. Everything else stays as it is, and because each enhancement keeps working as a normal link without the script, the risk is low. Measure before and after with the usual numbers: time to a visible result for a filter change, bytes transferred (a table fragment is a fraction of a full page), and the number of lines of client code you did not write. In this dashboard the output above shows 1,780 bytes for the full page of six rows against 436 bytes for the two-row failed filter, and on a real page with navigation, scripts and styles links that gap is larger.

Keep the script version pinned and reviewed like any dependency. HTMX is small, but it executes in your pages with the privileges of your origin, and a content security policy that forbids inline event handlers (which HTMX does not need) keeps the rest of your pages as locked down as they were.

When HTMX is the wrong choice

It shines when the server owns the data and the interface is a view of it: dashboards, admin screens, CRUD, forms with validation, search results. It is a poor fit for interactions that need instant local feedback with no round trip, such as drag-and-drop canvases, rich editors or offline-first apps, where the state is on the client and a framework earns its weight. For the typed-component version of this same project, see templ with HTMX, and for a larger worked example, the todo app.

Taggedproject
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.