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" |
<!-- 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>
<!-- 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">
<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>
<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>
w.Header().Set("HX-Retarget", "#error_modal_container")
w.Header().Set("Content-Type", "text/html")
<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"
}
<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>
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)
}
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>
}
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,
});
});
}
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>
}
}
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>
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
-- name: AddUser :one
insert into users (email) values ($1) returning *;
sqlc generate
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 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
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
}
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
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
[build]
bin = "./tmp/main"
cmd = "task build"
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
include_ext = ["go", "css", "templ"]
exclude_regex = ["_test.go"]