Skip to content
gohacks.dev
Web

Build an Admin Dashboard With Templ HTMX and Tailwind

Build an admin dashboard in Go with templ, HTMX and Tailwind classes: a shell, stat cards, a sortable paged table, role aware buttons and server side checks.

20 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the Tailwind CSS logo.

An admin dashboard is where a Go service meets its operators: lists of things, filters, buttons that change state, and numbers at a glance. It is also where server rendering is at its best, since the data is on the server, the audience is on a good network, and the interface is a view over rows. The hard parts are not visual. They are the dull-sounding matters of sort order, paging, permissions and what a request is allowed to say, and they are where internal tools quietly go wrong.

Tested with Go 1.25.6 and templ v0.3.1001, macOS on Apple Silicon (arm64)

This project builds one with templ for the components, HTMX for in-place updates and Tailwind-style utility classes for the look. It has a shared shell, stat cards, a searchable, sortable, paginated users table, role-aware actions and the server-side checks behind them. It follows the contacts directory and layouts, uses the role idea from RBAC middleware, and belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6 with templ v0.3.1001.

The components

admin.templText
package main

import (
	"context"
	"fmt"
)

type User struct {
	ID     int
	Name   string
	Email  string
	Role   string
	Joined string
	Active bool
}

type Stats struct {
	Total, Active, Admins int
}

type TableView struct {
	Rows    []User
	Sort    string
	Dir     string
	Page    int
	Pages   int
	Total   int
	Query   string
	IsAdmin bool
}

type ctxKey string

func roleOf(ctx context.Context) string {
	if r, ok := ctx.Value(ctxKey("role")).(string); ok {
		return r
	}
	return "viewer"
}

templ Shell(title, active string) {
	<!DOCTYPE html>
	<html lang="en" class="h-full bg-slate-50">
		<head>
			<meta charset="utf-8"/>
			<meta name="viewport" content="width=device-width, initial-scale=1"/>
			<title>{ title } | Admin</title>
			<script src="/static/htmx.min.js" defer></script>
			<link rel="stylesheet" href="/static/app.css"/>
		</head>
		<body class="flex min-h-full">
			<aside class="w-56 shrink-0 bg-slate-900 p-4 text-slate-100">
				<p class="mb-6 text-lg font-semibold">Acme Admin</p>
				<nav class="flex flex-col gap-1" aria-label="Main">
					@NavLink("/admin", "Overview", active == "overview")
					@NavLink("/admin/users", "Users", active == "users")
				</nav>
				<p class="mt-8 text-xs text-slate-400">Signed in as { roleOf(ctx) }</p>
			</aside>
			<main class="flex-1 p-8">
				<h1 class="mb-6 text-2xl font-bold text-slate-900">{ title }</h1>
				{ children... }
			</main>
		</body>
	</html>
}

templ NavLink(href, label string, active bool) {
	<a
		href={ templ.URL(href) }
		class={ "rounded px-3 py-2 text-sm", templ.KV("bg-slate-700 font-medium", active), templ.KV("hover:bg-slate-800", !active) }
		if active {
			aria-current="page"
		}
	>{ label }</a>
}

templ Overview(s Stats) {
	@Shell("Overview", "overview") {
		<div class="grid grid-cols-3 gap-4">
			@StatCard("Users", fmt.Sprint(s.Total), "all accounts")
			@StatCard("Active", fmt.Sprint(s.Active), fmt.Sprintf("%d%% of users", s.Active*100/max(s.Total, 1)))
			@StatCard("Admins", fmt.Sprint(s.Admins), "with write access")
		</div>
	}
}

templ StatCard(label, value, hint string) {
	<section class="rounded-lg bg-white p-4 shadow" aria-label={ label }>
		<p class="text-sm text-slate-500">{ label }</p>
		<p class="text-3xl font-semibold">{ value }</p>
		<p class="text-xs text-slate-400">{ hint }</p>
	</section>
}

templ UsersPage(v TableView) {
	@Shell("Users", "users") {
		<form method="get" action="/admin/users" class="mb-4">
			<input
				type="search"
				name="q"
				value={ v.Query }
				placeholder="Search users"
				class="w-72 rounded border px-3 py-2"
				hx-get="/admin/users"
				hx-trigger="input changed delay:300ms, search"
				hx-target="#table"
				hx-push-url="true"
			/>
		</form>
		@templ.Fragment("table") {
			@UsersTable(v)
		}
	}
}

templ UsersTable(v TableView) {
	<div id="table">
		<table class="w-full rounded-lg bg-white text-left text-sm shadow">
			<thead>
				<tr>
					@SortHeader("name", "Name", v)
					@SortHeader("email", "Email", v)
					@SortHeader("role", "Role", v)
					@SortHeader("joined", "Joined", v)
					<th scope="col" class="p-3"><span class="sr-only">Actions</span></th>
				</tr>
			</thead>
			<tbody>
				for _, u := range v.Rows {
					<tr id={ fmt.Sprintf("user-%d", u.ID) } class="border-t">
						<td class="p-3">{ u.Name }</td>
						<td class="p-3">{ u.Email }</td>
						<td class="p-3">{ u.Role }</td>
						<td class="p-3">{ u.Joined }</td>
						<td class="p-3">
							if v.IsAdmin {
								<button
									class="text-red-600"
									hx-post={ fmt.Sprintf("/admin/users/%d/delete", u.ID) }
									hx-target="#table"
									hx-swap="outerHTML"
									hx-confirm={ "Delete " + u.Name + "?" }
								>Delete</button>
							}
						</td>
					</tr>
				}
			</tbody>
		</table>
		@Pager(v)
	</div>
}

templ SortHeader(col, label string, v TableView) {
	<th
		scope="col"
		class="p-3"
		if v.Sort == col {
			aria-sort={ ariaSort(v.Dir) }
		}
	>
		<a
			href={ templ.URL(tableURL(v, col, nextDir(v, col), 1)) }
			hx-get={ tableURL(v, col, nextDir(v, col), 1) }
			hx-target="#table"
			hx-swap="outerHTML"
			hx-push-url="true"
		>{ label }</a>
	</th>
}

templ Pager(v TableView) {
	<nav class="mt-4 flex items-center gap-2 text-sm" aria-label="Pagination">
		<span>{ fmt.Sprint(v.Total) } users, page { fmt.Sprint(v.Page) } of { fmt.Sprint(v.Pages) }</span>
		if v.Page > 1 {
			<a href={ templ.URL(tableURL(v, v.Sort, v.Dir, v.Page-1)) } hx-get={ tableURL(v, v.Sort, v.Dir, v.Page-1) } hx-target="#table" hx-swap="outerHTML" hx-push-url="true">Previous</a>
		}
		if v.Page < v.Pages {
			<a href={ templ.URL(tableURL(v, v.Sort, v.Dir, v.Page+1)) } hx-get={ tableURL(v, v.Sort, v.Dir, v.Page+1) } hx-target="#table" hx-swap="outerHTML" hx-push-url="true">Next</a>
		}
	</nav>
}

A few points about the structure before the server code.

The shell owns the chrome. Shell renders the sidebar, the heading and the { children... } slot, and every page is a call like @Shell("Users", "users") { ... }. The second argument says which navigation entry is current, and NavLink marks it with aria-current="page" for assistive technology and with a different class for sighted users, both from one boolean. The signed-in role is read from the context, as in the layouts article, so no page passes it down.

Tailwind classes are written in full. Tailwind generates CSS by scanning your source files for class names it recognises, so a class assembled at runtime ("bg-" + color) is invisible to it and is never generated. Writing templ.KV("bg-slate-700 font-medium", active) keeps each name complete and greppable. The build step, which I have not run here because it needs the standalone Tailwind CLI, points the CLI at the .templ files: you give it an input CSS file, an output file and a content glob such as ./**/*.templ, and the output goes to static/app.css. Check the Tailwind documentation for the current invocation. For prototyping, the Play CDN script works, and it should not ship.

The table is a fragment. UsersPage wraps UsersTable in templ.Fragment("table"). Sorting links, the pager and the search box all target #table and ask for /admin/users with parameters; for an HTMX request the handler renders only the fragment. Each sort header is both an ordinary link (href) and an HTMX request (hx-get), so sorting works without JavaScript, and aria-sort on the active header tells screen readers how the column is ordered.

The server

app.goGo
package main

import (
	"cmp"
	"context"
	"fmt"
	"net/http"
	"net/url"
	"slices"
	"strconv"
	"strings"
	"sync"

	"github.com/a-h/templ"
)

const pageSize = 8

func ariaSort(dir string) string {
	if dir == "desc" {
		return "descending"
	}
	return "ascending"
}

func nextDir(v TableView, col string) string {
	if v.Sort == col && v.Dir == "asc" {
		return "desc"
	}
	return "asc"
}

func tableURL(v TableView, sortCol, dir string, page int) string {
	q := url.Values{"sort": {sortCol}, "dir": {dir}, "page": {strconv.Itoa(page)}}
	if v.Query != "" {
		q.Set("q", v.Query)
	}
	return "/admin/users?" + q.Encode()
}

// sortable is the allowlist: the query string picks a column by name, and only these names are accepted.
var sortable = map[string]func(a, b User) int{
	"name":   func(a, b User) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) },
	"email":  func(a, b User) int { return cmp.Compare(a.Email, b.Email) },
	"role":   func(a, b User) int { return cmp.Compare(a.Role, b.Role) },
	"joined": func(a, b User) int { return cmp.Compare(a.Joined, b.Joined) },
}

type Server struct {
	mu    sync.Mutex
	users []User
}

func NewServer() *Server {
	first := []string{"Ada", "Bo", "Cleo", "Dara", "Eli", "Fay", "Gus", "Hana", "Ivo", "Jun"}
	last := []string{"Stone", "Park", "Silva", "Novak", "Okafor", "Lindqvist"}
	s := &Server{}
	for i := 1; i <= 30; i++ {
		role := "member"
		if i%7 == 0 {
			role = "admin"
		}
		s.users = append(s.users, User{
			ID: i, Name: first[i%len(first)] + " " + last[i%len(last)], Email: fmt.Sprintf("user%02d@example.test", i),
			Role: role, Joined: fmt.Sprintf("2025-%02d-%02d", i%12+1, i%27+1), Active: i%4 != 0,
		})
	}
	s.users[4].Name = "Eve <script>alert(1)</script>" // hostile data stays text
	return s
}

// withRole stands in for real authentication: it reads a header. A real app reads a verified session.
func withRole(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		role := "viewer"
		if r.Header.Get("X-Role") == "admin" {
			role = "admin"
		}
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey("role"), role)))
	})
}

func (s *Server) Routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /admin", s.overview)
	mux.HandleFunc("GET /admin/users", s.users_)
	mux.HandleFunc("POST /admin/users/{id}/delete", s.remove)
	return withRole(vary(http.NewCrossOriginProtection().Handler(mux)))
}

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 isHX(r *http.Request) bool {
	return r.Header.Get("HX-Request") == "true" && r.Header.Get("HX-Boosted") != "true"
}

func (s *Server) overview(w http.ResponseWriter, r *http.Request) {
	s.mu.Lock()
	st := Stats{Total: len(s.users)}
	for _, u := range s.users {
		if u.Active {
			st.Active++
		}
		if u.Role == "admin" {
			st.Admins++
		}
	}
	s.mu.Unlock()
	templ.Handler(Overview(st)).ServeHTTP(w, r)
}

// view builds the table state from a query string, rejecting values that are not on the allowlists.
func (s *Server) view(q url.Values, isAdmin bool) (TableView, error) {
	v := TableView{Sort: "name", Dir: "asc", Page: 1, Query: strings.TrimSpace(q.Get("q")), IsAdmin: isAdmin}
	if c := q.Get("sort"); c != "" {
		if _, ok := sortable[c]; !ok {
			return v, fmt.Errorf("cannot sort by %q", c)
		}
		v.Sort = c
	}
	switch d := q.Get("dir"); d {
	case "", "asc", "desc":
		if d != "" {
			v.Dir = d
		}
	default:
		return v, fmt.Errorf("direction must be asc or desc")
	}
	s.mu.Lock()
	var all []User
	for _, u := range s.users {
		if v.Query == "" || strings.Contains(strings.ToLower(u.Name+" "+u.Email), strings.ToLower(v.Query)) {
			all = append(all, u)
		}
	}
	s.mu.Unlock()
	less := sortable[v.Sort]
	slices.SortStableFunc(all, func(a, b User) int {
		c := less(a, b)
		if c == 0 {
			c = cmp.Compare(a.ID, b.ID) // ties keep a stable order so paging does not repeat or skip rows
		}
		if v.Dir == "desc" {
			return -c
		}
		return c
	})
	v.Total = len(all)
	v.Pages = max((len(all)+pageSize-1)/pageSize, 1)
	if p, err := strconv.Atoi(q.Get("page")); err == nil {
		v.Page = min(max(p, 1), v.Pages)
	}
	start := (v.Page - 1) * pageSize
	v.Rows = all[start:min(start+pageSize, len(all))]
	return v, nil
}

func (s *Server) users_(w http.ResponseWriter, r *http.Request) {
	v, err := s.view(r.URL.Query(), roleOf(r.Context()) == "admin")
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	if isHX(r) {
		templ.Handler(UsersPage(v), templ.WithFragments("table")).ServeHTTP(w, r)
		return
	}
	templ.Handler(UsersPage(v)).ServeHTTP(w, r)
}

func (s *Server) remove(w http.ResponseWriter, r *http.Request) {
	if roleOf(r.Context()) != "admin" { // the server decides; hiding the button is a courtesy, not a control
		http.Error(w, "admins only", http.StatusForbidden)
		return
	}
	id, _ := strconv.Atoi(r.PathValue("id"))
	s.mu.Lock()
	i := slices.IndexFunc(s.users, func(u User) bool { return u.ID == id })
	if i >= 0 {
		s.users = slices.Delete(s.users, i, i+1)
	}
	s.mu.Unlock()
	if i < 0 {
		http.NotFound(w, r)
		return
	}
	q := url.Values{}
	if cu, err := url.Parse(r.Header.Get("HX-Current-URL")); err == nil {
		q = cu.Query() // redraw the table the way the admin was looking at it
	}
	v, err := s.view(q, true)
	if err != nil {
		v, _ = s.view(url.Values{}, true)
	}
	templ.Handler(UsersTable(v)).ServeHTTP(w, r)
}

view is the function that deserves the most attention, because it turns a query string into a decision about your data.

Allowlist, then sort. The sort parameter is an identifier. If the data came from SQL, passing it into an ORDER BY would be an injection hole (placeholders cannot stand in for column names), and the only safe way is a lookup table of allowed names, which is what sortable is. The same code works for in-memory data and keeps the habit: an unknown column or direction is a 400, never a guess.

A stable sort needs a tiebreaker. Several users share a role or a join date, and if the comparison returns equal, their relative order is undefined between requests. The result is the pager bug where a row appears on two pages and another on none. The fix is a final comparison on a unique key (ID), applied before the direction is flipped for desc. A test below checks every page of every sort.

Clamp, do not reject, the page number. A link from last week can point at a page that no longer exists after deletions. min(max(p, 1), v.Pages) turns any number, or no number, into a valid page, so the worst case is a different page and not an error.

The server decides permissions. The IsAdmin flag removes the Delete buttons from a viewer's table. That is a convenience. The remove handler checks the role itself and answers 403 otherwise, because anyone can send a POST without having seen the button. The withRole middleware here reads a header, a stand-in for a verified session, and the comment in the code says so; nothing in a production service should trust a role header from a client. After a delete, the handler rebuilds the table from the query string of the page the admin was on (HX-Current-URL), so the sort and page survive.

The routes are wrapped in Go 1.25's http.NewCrossOriginProtection(), the check described in the forms article, so a forged cross-site POST to /admin/users/2/delete is refused with no token machinery.

Running it

main.goGo
package main

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

func main() {
	srv := httptest.NewServer(NewServer().Routes())
	defer srv.Close()
	do := func(method, path string, hx bool, hdr ...string) (*http.Response, string) {
		req, _ := http.NewRequest(method, srv.URL+path, nil)
		if hx {
			req.Header.Set("HX-Request", "true")
		}
		for i := 0; i+1 < len(hdr); i += 2 {
			req.Header.Set(hdr[i], hdr[i+1])
		}
		res, _ := http.DefaultClient.Do(req)
		defer res.Body.Close()
		b, _ := io.ReadAll(res.Body)
		return res, string(b)
	}
	names := regexp.MustCompile(`<tr id="user-(\d+)" class="border-t"><td class="p-3">([^<]*)</td>`)
	rows := func(s string) string {
		var out []string
		for _, m := range names.FindAllStringSubmatch(s, -1) {
			out = append(out, m[1])
		}
		return strings.Join(out, ",")
	}
	info := regexp.MustCompile(`(\d+) users, page (\d+) of (\d+)`)

	_, body := do("GET", "/admin", false)
	fmt.Println("overview cards:", strings.Count(body, "<section"), "| 30 users:", strings.Contains(body, ">30<"), "| nav marks Overview current:", strings.Contains(body, `aria-current="page">Overview`))
	_, body = do("GET", "/admin/users", false)
	fmt.Println("default users page:   rows", rows(body), info.FindString(body), "| aria-sort on Name:", strings.Contains(body, `aria-sort="ascending"`))
	_, body = do("GET", "/admin/users?sort=name&dir=desc&page=2", true)
	fmt.Println("name desc, page 2:    rows", rows(body), info.FindString(body), "| fragment only:", !strings.Contains(body, "<html"))
	_, body = do("GET", "/admin/users?sort=joined&dir=asc", true)
	fmt.Println("joined asc:           rows", rows(body))
	_, body = do("GET", "/admin/users?q=user07", true)
	fmt.Println("search user07:        rows", rows(body), info.FindString(body))
	_, body = do("GET", "/admin/users?page=99", true)
	fmt.Println("page 99 is clamped:   ", info.FindString(body))
	res, body := do("GET", "/admin/users?sort=password", true)
	fmt.Println("sort by a bad column: ", res.StatusCode, strings.TrimSpace(body))
	res, body = do("GET", "/admin/users?dir=sideways", true)
	fmt.Println("bad direction:        ", res.StatusCode, strings.TrimSpace(body))

	_, body = do("GET", "/admin/users", false)
	_, adminBody := do("GET", "/admin/users", false, "X-Role", "admin")
	fmt.Println("delete buttons for a viewer:", strings.Count(body, "Delete "), "| for an admin:", strings.Count(adminBody, "hx-confirm"))
	_, body = do("GET", "/admin/users?q=eve", false)
	fmt.Println("hostile name escaped:", strings.Contains(body, "Eve &lt;script&gt;") && !strings.Contains(body, "<script>alert"))

	res, _ = do("POST", "/admin/users/1/delete", true)
	fmt.Println("viewer deletes:", res.StatusCode)
	res, body = do("POST", "/admin/users/1/delete", true, "X-Role", "admin", "HX-Current-URL", srv.URL+"/admin/users?sort=name&dir=desc")
	fmt.Println("admin deletes user 1:", res.StatusCode, "|", info.FindString(body), "| still sorted desc:", strings.Contains(body, `aria-sort="descending"`))
	res, _ = do("POST", "/admin/users/999/delete", true, "X-Role", "admin")
	fmt.Println("admin deletes a ghost:", res.StatusCode)
	res, _ = do("POST", "/admin/users/2/delete", true, "X-Role", "admin", "Sec-Fetch-Site", "cross-site")
	fmt.Println("cross-site delete:", res.StatusCode)
}
Shell
go get github.com/a-h/[email protected] >/dev/null 2>&1
go get -tool github.com/a-h/templ/cmd/[email protected] >/dev/null 2>&1
go tool templ generate >/dev/null 2>&1
go run .
Text
overview cards: 3 | 30 users: true | nav marks Overview current: true
default users page:   rows 10,20,30,11,21,1,22,2 30 users, page 1 of 4 | aria-sort on Name: true
name desc, page 2:    rows 17,6,26,16,25,15,5,24 30 users, page 2 of 4 | fragment only: true
joined asc:           rows 12,24,1,13,25,2,14,26
search user07:        rows 7 1 users, page 1 of 1
page 99 is clamped:    30 users, page 4 of 4
sort by a bad column:  400 cannot sort by "password"
bad direction:         400 direction must be asc or desc
delete buttons for a viewer: 0 | for an admin: 8
hostile name escaped: true
viewer deletes: 403
admin deletes user 1: 200 | 29 users, page 1 of 4 | still sorted desc: true
admin deletes a ghost: 404
cross-site delete: 403

The output follows the design. The overview shows three stat cards, a total of 30 and the navigation entry marked current. The default users page is sorted by name ascending, shows eight of 30 users on page 1 of 4, and marks the Name header with aria-sort. Page 2 of a descending name sort comes back as a fragment with no <html>. The joined sort, a search for user07 (one match), and page 99 (clamped to page 4) all behave. A bad column and a bad direction are 400s with plain messages.

A viewer's table has no Delete buttons; an admin's has eight, one per row. The user whose name contains a <script> tag is displayed as text. A viewer's POST gets 403, an admin's succeeds and returns the table at 29 users still sorted descending (the sort survived because the handler read it back), a delete for a user who does not exist is 404, and a cross-site POST is refused with 403.

Tests for the rules

admin_test.goGo
package main

import (
	"net/url"
	"strconv"
	"strings"
	"testing"
)

func TestEveryPageOfEverySortShowsEachUserOnce(t *testing.T) {
	s := NewServer()
	for col := range sortable {
		for _, dir := range []string{"asc", "desc"} {
			seen := map[int]int{}
			for page := 1; page <= 4; page++ {
				v, err := s.view(url.Values{"sort": {col}, "dir": {dir}, "page": {strconv.Itoa(page)}}, false)
				if err != nil {
					t.Fatal(err)
				}
				for _, u := range v.Rows {
					seen[u.ID]++
				}
			}
			if len(seen) != 30 {
				t.Errorf("sort=%s dir=%s covered %d users, want 30", col, dir, len(seen))
			}
			for id, n := range seen {
				if n != 1 {
					t.Errorf("sort=%s dir=%s showed user %d %d times", col, dir, id, n)
				}
			}
		}
	}
}

func TestNameSortIgnoresCase(t *testing.T) {
	v, _ := NewServer().view(url.Values{"sort": {"name"}}, false)
	for i := 1; i < len(v.Rows); i++ {
		if strings.ToLower(v.Rows[i-1].Name) > strings.ToLower(v.Rows[i].Name) {
			t.Fatalf("%q before %q", v.Rows[i-1].Name, v.Rows[i].Name)
		}
	}
}

func TestOnlyAllowlistedColumnsSort(t *testing.T) {
	s := NewServer()
	for _, col := range []string{"password", "name; DROP TABLE users", "ID", "../etc/passwd"} {
		if _, err := s.view(url.Values{"sort": {col}}, false); err == nil {
			t.Errorf("sorting by %q was accepted", col)
		}
	}
}

func TestPageNumbersAreClamped(t *testing.T) {
	s := NewServer()
	for _, p := range []string{"0", "-5", "9999", "abc", ""} {
		v, err := s.view(url.Values{"page": {p}}, false)
		if err != nil || v.Page < 1 || v.Page > v.Pages || len(v.Rows) == 0 {
			t.Errorf("page %q gave page %d of %d with %d rows, %v", p, v.Page, v.Pages, len(v.Rows), err)
		}
	}
}
Shell
go get github.com/a-h/[email protected] >/dev/null 2>&1
go get -tool github.com/a-h/templ/cmd/[email protected] >/dev/null 2>&1
go tool templ generate >/dev/null 2>&1
go test -race -count=1 -v ./... 2>&1 | grep -E "^(--- |ok|FAIL|PASS)"
Text
--- PASS: TestEveryPageOfEverySortShowsEachUserOnce (0.00s)
--- PASS: TestNameSortIgnoresCase (0.00s)
--- PASS: TestOnlyAllowlistedColumnsSort (0.00s)
--- PASS: TestPageNumbersAreClamped (0.00s)
PASS
ok  	example.test/lab	1.278s

The first test is the one that pays for the tiebreaker: for every sortable column in both directions, it walks all four pages and requires each of the 30 users to appear exactly once. The others pin the case-insensitive name sort, the allowlist (including SQL and path-traversal strings) and the clamping of page numbers.

Table design details that matter

A table in an admin tool is used all day, so small decisions repeat thousands of times. Give the table an accessible name, either a <caption> or aria-labelledby pointing at the page heading, so a screen reader announces what it is entering. Use scope="col" on header cells, as the component does, and keep the Actions column header readable to assistive technology with a visually hidden label (sr-only) instead of an empty cell. Sorting state belongs in the markup (aria-sort) and in the URL, so a reload keeps the view and a colleague can be sent a link to exactly what you are looking at.

Keep rows predictable. The delete button is the same component in every row, with a confirmation text that names the user (hx-confirm), because "Delete?" repeated forty times invites mistakes. For long tables, a sticky header and a fixed column layout keep the eye anchored, and number columns align right. None of this is templ-specific, and all of it is easy to apply once the table is a single component with one place to change.

Numbers that update

The stat cards on the overview page are computed on each request, which is correct and cheap for a few counts. When a number is expensive (a count over millions of rows, a metric from another service), compute it on a schedule and cache the result with a timestamp, and show the age in the card ("updated 2 minutes ago") so nobody mistakes a cached value for live data. If a card should refresh itself, give it hx-get to a small endpoint with hx-trigger="every 30s", as in the dashboard article; keep that endpoint behind the same authorisation as the page, since it exposes the same numbers. For charts, render a small SVG on the server or ship a charting library as a self-hosted script that reads data from a JSON script element (templ.JSONScript writes one safely), and keep the data endpoint as strict as any other.

Small screens and keyboard use

An admin tool is used on laptops mostly and on phones when something is on fire, so the layout should not collapse badly. The shell above uses a fixed sidebar for brevity. A real one hides the sidebar behind a menu button below a breakpoint (Tailwind's md: prefix), lets tables scroll horizontally inside a container instead of overflowing the page, and keeps the primary actions reachable. Test with the keyboard alone: the links, sort headers, pager and buttons here are all real a and button elements, so Tab, Enter and Space work, and the focus order follows the visual order. Visible focus styles are a CSS concern that Tailwind makes easy and that internal tools skip too often.

Why this shape works for a team

The reason I like this structure for internal tools is how little there is to explain to the next developer. Pages are components. The table is one component with one query-string contract, and every control that changes the view (sort, search, page) is a link to the same URL with different parameters, which HTMX upgrades to an in-place update. There is no client-side state to debug, because the state is the URL and the database. A new column means one line in the sortable map, one SortHeader call and one cell; a new filter means one more query parameter parsed in view. The tests that matter, covering every page of every sort, the allowlist and the clamping, live next to the code that needs them and run in milliseconds. And because the server decides everything, the security review is a short read of two functions: view, which turns input into a query, and remove, which turns a request into a change.

Rolling out changes to an admin tool

Admin interfaces change under their users, who are your colleagues and who will tell you exactly what broke. Treat each release as you would a public one: keep URLs stable so bookmarks and runbooks survive, keep query parameter names stable for the same reason (a link in an incident document that sorts a table by a column should still sort it next quarter), and announce renamed screens. Feature flags help when a new table replaces an old one; render the old and new side by side for a few people first. Because the dashboard is a Go binary, rollback is deploying the previous one, and the audit trail should outlive any single version, which is one more reason to store it in the database and not in the application's logs.

What to add before real use

Real authentication and authorisation. Replace withRole with sessions or tokens (authentication middleware), and check permissions per action: the remove handler's role test generalises to a policy function (can(user, "delete", target)) called by every mutating handler. Log every admin action with who, what and when; an audit trail is part of an admin tool, not an extra.

Real data. Move the slice to SQL with keyset or offset paging, as discussed in pagination, and keep the allowlist for sort columns. Index the columns people sort by, or the sortable headers become the slowest queries in the system.

Bulk actions and undo. Dashboards grow checkboxes. The shape stays the same: a form posting a list of IDs, a handler that validates each against the caller's permissions, and a response that redraws the table. Prefer soft deletes with an undo for anything destructive, as in soft delete and audit trails.

Operations. Put the dashboard on its own path prefix or host, require a second factor for admins, set strict Cache-Control: no-store on authenticated pages, and keep the console out of search engines (robots rules do not hide an address; authentication does). The component and fragment APIs are in the templ guide.

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.