What I learnt building a web app with Go and HTMX

Introduction

Who is this for?

  • Backend Developers
    • No JS
  • Manage state in one place

Story Time

Tech Stack (Backend)

  • Go
  • Postgres
  • Templ

Tech Stack (Frontend)

  • HTMX
  • TailwindCSS
  • AlpineJS

HTMX

javascript fatigue:
longing for a hypertext
already in hand

htmx.org

┌─────────┐    ┌──────┐    ┌─────────┐
│ Browser │    │ HTMX │    │ Server  │
└─────────┘    └──────┘    └─────────┘
     │            │            │
     │ User Event │            │
     ├───────────►│            │
     │            │ HTTP Req   │
     │            ├───────────►│
     │            │ HTML Resp  │
     │            │◄───────────┤
     │ DOM Update │            │
     │◄───────────┤            │
Attribute Purpose Example
hx-get GET request hx-get="/users"
hx-post POST request hx-post="/users"
hx-trigger Event trigger hx-trigger="click"
hx-target Target element hx-target="#result"
hx-swap Swap strategy hx-swap="innerHTML"

Swap Strategies

<!-- Replace inner content -->
<div hx-swap="innerHTML">...</div>

<!-- Replace entire element -->
<div hx-swap="outerHTML">...</div>

<!-- Insert at beginning -->
<div hx-swap="afterbegin">...</div>

<!-- Insert at end -->
<div hx-swap="beforeend">...</div>

Advanced Triggers

<!-- Trigger on page load -->
<div hx-get="/data" hx-trigger="load">

<!-- Trigger on intersection (lazy loading) -->
<div hx-get="/more" hx-trigger="intersect once">

<!-- Debounced input -->
<input hx-get="/search"
       hx-trigger="keyup changed delay:500ms">

<!-- Multiple triggers -->
<div hx-get="/refresh"
     hx-trigger="click, every 30s">

Loading Indicators

<button hx-post="/submit"
        hx-indicator=".loading">
    <span class="htmx-show">Submit</span>
    <span class="loading htmx-indicator">
        Submitting...
    </span>
</button>
<script src="https://unpkg.com/[email protected]"></script>
<script
   src="https://unpkg.com/htmx.org/dist/ext/json-enc.js">
</script>

HTMX

<form
    class="space-y-4"
    hx-post="/waitlist"
    hx-target="#container"
    hx-swap="innerHTML"
    hx-ext="json-enc"
>
    <label class="w-full input validator">
        <i class="h-6 hgi hgi-solid hgi-tick-02"></i>
        <input
            type="email"
            name="email"
            placeholder="[email protected]"
            required
        />
    </label>
    <div class="hidden validator-hint">
        Enter valid email address
    </div>
    <button
        type="submit"
        class="p-4 transition-colors btn btn-neutral btn-block hover:bg-secondary hover:text-neutral"
        hx-indicator=".hx-indicator"
        hx-disabled-elt="this"
    >
        <span class="htmx-show">Send Magic Link ✨</span>
        <span class="hidden justify-center items-center hx-indicator">
            <span class="loading loading-spinner"></span>
            <span class="ml-2">Sending...</span>
        </span>
    </button>
</form>

<div id="container"></div>
type Waitlist struct {
	Email string `json:"email"`
}
<div class="p-8 space-y-6 text-center">
    <div class="flex justify-center text-neutral">
        <i class="h-10 text-neutral hgi hgi-solid hgi-tick-02"></i>
    </div>
    <h3 class="text-2xl font-semibold">
        You're on the Waitlist 🎉
    </h3>
    <div class="space-y-6">
        <p>Thank you for your interest in our application.</p>
        <p>
            We'll notify you at
            <br/>
            <span class="font-mono text-primary">
                [email protected]
            </span>
            <br/>
            when we're ready to launch.
        </p>
    </div>
</div>

Why HTMX?

  • State on backend
  • Reduced complexity
  • Simpler tooling

What about JSON?

  • Separate API
  • Mobile vs WebApp
w.Header().Set("HX-Retarget", "#error_modal_container")
w.Header().Set("Content-Type", "text/html")

WebSockets

<div
     hx-ext="ws"
     ws-connect="/ws">

    <form
        hx-vals='{"message_type": "submit_vote" }'
        ws-send
    >
        <input name="voted_player_nickname" />
    </form>
</div>
{
    "message_type": "submit_vote",
    "voted_player_nickname": "majiy"
}

Response Codes

  • 204 - No Content
  • 304 - Not Modified
  • 4xx - Client errors
  • 5xx - Server errors

AlpineJS

<script
src="https://cdn./.../[email protected]/dist/cdn.min.js">
</script>
<div
    x-data={ "showModal": false }
    @keydown.escape="showModal = false"
>
    <button type="button" @click="showModal = true">
        <i class="hgi-information-circle"></i>
    </button>
    <div x-show="showModal">modal</div>
</div>

Alternatives

  • Datastar
  • Alpine AJAX

Backend

Handler

type Waitlist struct {
	Email string `json:"email"`
}

func (h *Handler) AddToWaitlist(
    w http.ResponseWriter,
    r *http.Request,
) {
    var req Waitlist
    body, _ := io.ReadAll(r.Body)
    json.Unmarshal(body, &req)

    waitlist, err := h.service.AddToWaitlist(
        r.Context(),
        req.Email,
    )
    if err != nil {
        http.Error(w,
            err.Error(),
            http.StatusInternalServerError,
        )
        return
    }

    components.Waitlist(waitlist.Email).
        Render(r.Context(), w)
}

Templ

  • HTML Templates
  • LSP
  • Components
package sections

import (
	"gitlab.com/hmajid2301/banterbus/internal/service"
	"gitlab.com/.../internal/views/blocks"
)

templ Winner(state service.WinnerState, maxScore int) {
<div hx-swap-oob="innerHTML:#page">
    <div>
        <div class="flex">
            <div class="grid>
                <div>
                    The winner is
                    { state.WinnerPlayer.Nickname }
                </div>
                @blocks.Scoreboard(
                    state.Players,
                    maxScore,
                )
            </div>
        </div>
    </div>
</div>
}

scripts.templ

templ Scripts(environment string) {
<script src="https://unpkg.com/[email protected]">
</script>
<script src=".../dist/ext/json-enc.js">
</script>
<script src=".../[email protected]/dist/cdn.min.js">
</script>
@sentryLoad(environment)
}
script sentryLoad(environment string) {
  Sentry.onLoad(function() {
    Sentry.init({
        environment: environment,
    });
  });
}

layout.templ

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 Dashboard(title string, environment string) {
	@Base(title, environment) {
        <div class="drawer lg:drawer-open">
        </div>
    }
}

i18n

func (m Middleware) Locale(next http.Handler)
http.Handler {
    return http.HandlerFunc(
        func(w http.ResponseWriter, r *http.Request) {
            locale := extractLocaleFromURL(r.URL.Path)
            ctx, err := ctxi18n.WithLocale(
                r.Context(),
                locale,
            )
            next.ServeHTTP(w, r.WithContext(ctx))
    })
}
component := sections.Winner(winnerState, maxScore)
err := component.Render(r.Context(), &buf)
if err != nil {
    return err
}
en-GB:
  common:
    ready_button: "Ready"
    roomcode_label: "Room Code"
  home:
    start_button_label: "Start Game"
<div>
    { i18n.T(ctx, "common.ready_button") }
</div>
<div class="...">
    Ready
</div>

Postgres

sqlc

version: "2"
sql:
  - engine: "postgresql"
    queries: "internal/store/db/sqlc/query.sql"
    schema: "internal/store/db/sqlc/migrations"
    gen:
      go:
        package: "db"
        out: "internal/store/db"
        sql_package: "pgx/v5"
        emit_interface: true

query.sql

-- name: AddUser :one
insert into users (email) values ($1) returning *;
sqlc generate

generated

const addUser = `-- name: AddUser :one
insert into users (email) values ($1) returning id, created_at, updated_at, email
`

func (q *Queries) AddUser(
    ctx context.Context,
    email string,
) (User, error) {
	row := q.db.QueryRow(ctx, addUser, email)
	var i User
	err := row.Scan(
		&i.ID,
		&i.CreatedAt,
		&i.UpdatedAt,
		&i.Email,
	)
	return i, err
}
type Querier interface {
	AddFibbingItRole(ctx context.Context, arg AddFibbingItRoleParams) (FibbingItPlayerRole, error)
	AddPlayer(ctx context.Context, arg AddPlayerParams) (Player, error)
	AddQuestion(ctx context.Context, arg AddQuestionParams) (Question, error)
	AddQuestionTranslation(ctx context.Context, arg AddQuestionTranslationParams) (QuestionsI18n, error)
	AddRoom(ctx context.Context, arg AddRoomParams) (Room, error)
	GetAllPlayerByRoomCode(ctx context.Context, roomCode string) ([]GetAllPlayerByRoomCodeRow, error)
    // ...
}

Goose

-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS feedback (
    id UUID PRIMARY KEY DEFAULT generate_uuidv7(),
    created_at TIMESTAMP DEFAULT current_timestamp,
    updated_at TIMESTAMP DEFAULT current_timestamp,
    title TEXT NOT NULL,
    description TEXT NOT NULL,
);
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS feedback;
-- +goose StatementEnd

Transactions

func (s *DB) StartGame(ctx context.Context, arg StartGameArgs) error {
return s.Transaction(ctx, func(q *Queries)
error {
// Update room state
_, err := q.UpdateRoomState(ctx, UpdateParams{
    RoomState: Playing.String(),
    ID:        arg.RoomID,
})
if err != nil {
    return err
}

// Add game state
_, err = q.AddGameState(ctx, AddGameStateParams{
    ID:     arg.GameStateID,
    RoomID: arg.RoomID,
    State:  FibbingITQuestion.String(),
})
if err != nil {
    return err
}

// Assign roles to players
for i, player := range arg.Players {
    role := "normal"
    if i == arg.FibberLoc { role = "fibber" }

    _, err = q.AddFibbingItRole(ctx, AddFibbingItRoleParams{
        PlayerID: player.ID, Role: role,
    })
    if err != nil {
        return err
    }
}
return nil
})
}
type Storer interface {
	db.Querier
	StartGame(ctx context.Context, arg db.StartGameArgs) error
}

DevEx

docker-compose.yml

services:
  postgres:
    image: postgres:17.4
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql

Taskfile.yml

version: "3"

tasks:
  build:
    desc: Build the binary in a tmp location.
    cmds:
      - go build -o ./tmp/main ./cmd/server/main.go

  dev:
    desc: Start the app in dev mode with live-reloading.
    dotenv:
      - .env.local
    cmds:
      - docker compose up -d
      - task: watch
      - air

.air.toml

[build]
bin = "./tmp/main"
cmd = "task build"
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
include_ext = ["go", "css", "templ"]
exclude_regex = ["_test.go"]

When not to use HTMX?

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

Other Issues?

  • Alpine: Stringified JS
  • Templ: Another tool
  • SQLC: Dynamic queries

Further

  • Observability
    • OTel
  • Playwright
    • Go