What I learnt building a web app with Go and HTMX

Introduction

  • Haseeb Majid
    • Backend Software Engineer at Nala
  • Loves cats 🐱
  • Avid cricketer 🏏 #BazBall
  • 🌱 Amateur Gardener

Who Is This For?

  • Backend engineers
  • Simple Stack

Banter Bus got me here, LettuceGo made the same lessons easier to show.

What is HTMX?

  • Small library
  • Interact with backend
    • Using HTML attributes
sequenceDiagram
    participant User
    participant HTMX
    participant Server

    User->>HTMX: Click / submit
    HTMX->>Server: HTTP request
    Server-->>HTMX: HTML fragment
    HTMX-->>User: DOM swap

<form hx-post="/plants/timeline"
      hx-target="#timeline"
>
   <div class="flex flex-col gap-4">
       <input name="event_type"
              type="radio"
              value="repotted"
              checked />
       <textarea name="notes" rows="3"></textarea>

       <button type="submit">Add Event</button>
   </div>
</form>

Why HTMX?

  • State on backend
  • Simpler tooling
<input
    name="q"
    hx-get="/plants"
    hx-trigger="input changed delay:300ms"
    hx-target="#plants-list"
    hx-push-url="true"
/>

Svelte

<input bind:value={q} placeholder="Search plants" />
<script lang="ts">
  let q = ''
  let plants = []

  $: params = new URLSearchParams({ q })
</script>
<script lang="ts">
  $: if (q || location) {
    loadPlants()
    history.replaceState({}, '', `?${params}`)
  }

  async function loadPlants() {
    const res = await fetch(`/plants?${params}`)
    plants = await res.json()
  }
</script>
{#if loading}
  <p>Loading...</p>
{:else if error}
  <p>{error}</p>
{:else}
  <ul>
    {#each plants as plant}
      <li>{plant.name}</li>
    {/each}
  </ul>
{/if}
<div
    hx-get="/plants/plant_1_id/timeline"
    hx-trigger="load"
>
    <p class="text-sm text-muted">Loading...</p>
</div>
flowchart LR
    A[POST health entry]
    B[record health]
    C[toast]
    D[health dot]
    E[timeline]
    F[last event]
    G[history]

    A --> B
    B --> C
    B --> D
    B --> E
    B --> F
    B --> G

<div id="toast-container" hx-swap-oob="true">
  Health recorded!
</div>

<div id="health-dot-plant-1-id" hx-swap-oob="true">
  <span class="bg-health-4"></span>
</div>

<div id="timeline-section" hx-swap-oob="true">
  <!-- updated timeline -->
</div>

<div id="last-event-oob" hx-swap-oob="true">
  Repotted — just now
</div>

AlpineJS

<div x-data="{ open: false }">
  <button @click="open = true">Delete plant</button>

  <dialog x-show="open">
    <p>Are you sure?</p>
    <button hx-delete="/plants/123"
            hx-target="#plants-list">Yes
    </button>
    <button @click="open = false">Cancel</button>
  </dialog>
</div>
func ErrorWithToast(
    w http.ResponseWriter,
    r *http.Request,
    statusCode int,
    message string,
) {
    w.Header().Set("HX-Retarget", "#toast-container")
    w.Header().Set("HX-Reswap", "innerHTML")
    w.WriteHeader(statusCode)
    _ = components.Toast(
        message,
        components.ToastError,
    ).Render(r.Context(), w)
}

Redirects

func authRedirect(w http.ResponseWriter, r *http.Request) {
    if r.Header.Get("HX-Request") == "true" {
        w.Header().Set("HX-Redirect", "/login")
        w.WriteHeader(http.StatusOK)
        return
    }

    http.Redirect(w, r, "/login", http.StatusFound)
}

What About JSON?

  • Separate Data API
    • Generic
      • N+1 query problem
  • Mobile vs Web App

When not to use HTMX?

  • Lots of frontend reactivity
  • Separate frontend/backend teams
  • Design System

Backend

flowchart LR
    subgraph Browser
        A[DOM]
        H[HTMX]
        A -->|User event| H
        H -->|Swap target| A
    end

    subgraph GoApp[Go application]
        B[Handler]
        C[Service]
        D[sqlc]
        F[templ]

        B --> C
        C --> D
        B --> F
    end

    E[(Postgres)]

    H -->|HTTP request| B
    D <--> E
    F -->|HTML fragment| H

Handler

func (h *EventHandler) UpdateTimeline(
    w http.ResponseWriter,
    r *http.Request,
) {
    // ...

    timeline, err := h.service.UpdatePlantTimeline(
        r.Context(),
        plantID,
    )
    if err != nil {
        response.ErrorWithToast(
            w, r,
            http.StatusInternalServerError,
            "Failed to load timeline",
        )
        return
    }

    err = plantcomponents.Timeline(
        plantID,
        timeline,
    ).Render(r.Context(), w)
    if err != nil {
        h.logger.ErrorContext(
            r.Context(),
            "failed to render timeline content",
            slog.Any("error", err),
        )
    }
}

Templ

  • HTML Templates
  • LSP
  • Components

package components

import "gitlab.com/.../service"

templ HealthHistory(entries []service.HealthEntry) {
    for _, entry := range entries {
        <div class="flex gap-3 items-center py-2">
            @Health(entry.Rating)
            <span class="text-sm font-medium text-text">
                { entry.Rating }
            </span>
        </div>
    }
}
<div class="flex gap-3 items-center py-2">
    <span class="inline-block w-3 h-3 rounded-full bg-health-3"></span>
    <span class="text-sm font-medium text-text">
        3
    </span>
</div>
templ generate
// Code generated by templ - DO NOT EDIT.
func HealthHistory(entries []service.HealthEntry) templ.Component {
	return templruntime.GeneratedTemplate(func(...) (err error) {
		for _, entry := range entries {
			templruntime.WriteString(buf, 1, "<div class=\"flex gap-3 items-center py-2\">")
			Health(entry.Rating).Render(ctx, buf)
			templruntime.WriteString(buf, 2, "<span class=\"text-sm font-medium text-text\">")
			buf.WriteString(entry.Rating)
			templruntime.WriteString(buf, 3, "</span></div>")
		}
		return
	})
}
package layouts

import "gitlab.com/.../http/views/components"

templ Base(title string, environment string) {
	<!DOCTYPE html>
	<html lang="en">
		<head>
		</head>
		<body class="bg-base-200 text-neutral">
			{ children... }
		</body>
	</html>
}
templ PlantPage(title string, environment string) {
	@Base(title, environment) {
          <div>plant1</div>
          // ...
    }
}
<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Basil</title>
  </head>
  <body class="bg-base-200 text-neutral">
      <div>plant1</div>
  </body>
</html>

Postgres

SQLC

-- name: ListHealthByPlant :many
SELECT id, plant_id, rating, recorded_at
FROM health_entries
WHERE plant_id = $1
ORDER BY recorded_at DESC
LIMIT $2 OFFSET $3;
sqlc generate

sqlc.yaml

version: "2"
sql:
  - engine: "postgresql"
    queries: "internal/store/db/sqlc/queries"
    schema: "internal/store/db/sqlc/migrations"
    gen:
      go:
        package: "db"
        out: "internal/store/db"
        sql_package: "pgx/v5"
type HealthEntry struct {
    ID         uuid.UUID
    PlantID    uuid.UUID
    UserID     uuid.UUID
    Rating     int32
    Notes      *string
    RecordedAt pgtype.Timestamptz
    CreatedAt  pgtype.Timestamptz
    UpdatedAt  pgtype.Timestamptz
}

type ListHealthByPlantParams struct {
    PlantID uuid.UUID
    Limit   int32
    Offset  int32
}

func (q *Queries) ListHealthByPlant(
    ctx context.Context,
    arg ListHealthByPlantParams,
) ([]HealthEntry, error) {
    rows, err := q.db.Query(
        ctx, listHealthByPlant,
        arg.PlantID, arg.Limit, arg.Offset,
    )
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var items []HealthEntry
    for rows.Next() {
        var i HealthEntry
        if err := rows.Scan(
            &i.ID, &i.PlantID, &i.UserID, &i.Rating,
            &i.Notes, &i.RecordedAt, &i.CreatedAt,
            &i.UpdatedAt,
        ); err != nil {
            return nil, err
        }
        items = append(items, i)
    }
    if err := rows.Err(); err != nil {
        return nil, err
    }
    return items, nil
}

DevEx

Taskfile

version: "3"

tasks:
  dev:
    cmds:
      - docker compose up -d
      - concurrently "air" "task watch"

  watch:
    cmds:
      - templ generate -watch &
      - tailwindcss -i ./static/css/tailwind.css \
          -o ./static/css/styles.css --minify --watch

Live Reload

import (
    "github.com/aarol/reload"
)

func (s *Server) wrapWithReload(handler http.Handler) http.Handler {
    reloader := reload.New(
        "internal/transport/http/views",
        "static/css",
    )
    s.logger.Debug("starting reload server")
    return reloader.Handle(handler)
}

flake.nix

myPackages = with pkgs; [
  go
  goose
  air
  golangci-lint
  gofumpt
  go-task
  playwright-driver
  playwright-cli
  templ
  sqlc
  tailwindcss_4
];

devShellPackages =
  with pkgs;
  myPackages;

Nix

example on main via 🐹 v1.22.8
❯ which golangci-lint

example on main via 🐹 v1.22.8
❯ nix develop

example on main via 🐹 v1.22.8 ❄️ impure (nix-shell-env)
❯ which golangci-lint
/nix/store/kcd...golangci-lint-1.56.2/bin/golangci-lint

Playwright

func TestTimelineRefreshesAfterAddEvent(t *testing.T) {
    // setup ...

    page.Locator(
        "a:has-text('Timeline Refresh Plant')",
    ).First().Click()

    timelineSection := page.Locator(
        "#timeline-section",
    )
    expect.Locator(timelineSection).ToContainText(
        "Repotted",
    )

    lastEventOOB := page.Locator("#last-event-oob")
    expect.Locator(lastEventOOB).ToContainText("—")
}

Takeaways

  • HTMX
  • Go + Templ
  • sqlc