Go Performance Starter
A server-rendered Go + HTMX starter that proves its own architecture: typed templates, RLS-scoped data access, and performance budgets enforced in CI — demonstrated live by the surfaces below.
Pattern Showcase
Every HTMX and Alpine.js pattern the starter supports, live, with the templ and handler source beside each demo.
Architecture Quiz
Questions drawn from this system's own architecture — every answer is a real row persisted behind Row Level Security.
Flashcards
Wrong quiz answers become saveable flashcards: review, flip, mark known, delete — per-user CRUD through the RLS-scoped repositories, plus an isolation check that runs your own query as a stranger.
Dashboard
Your progress, live from your own rows: quiz score history and cards to review, loaded with the skeleton pattern.
Architecture tour
Follow one request through the stack
Five stops from the socket to the pixel. Each is a live part of this very page, links to the decision record that shaped it, and feeds the quiz — so what you read here is what you'll be asked.
Step 1: Request in: Chi router + middleware stack
One deliberate chain — security headers, request ID, trusted-proxy client IP, rate limits, compression, metrics, logging, recovery, timeout, CSRF — then the session middlewares per route group.
Every request enters through a single chi router whose middleware order is a design decision, not an accident: SecurityHeaders first, RealIP before the rate limiter (so limits key on the real client behind the Cloudflare proxy), CSRF before any handler runs.
Identity is layered per route group rather than globally. Public pages carry no session at all; /learn mounts GuestSession → OptionalAuth → OptionalUserLoader so a visitor gets a real anonymous Supabase identity on first touch; /profile and /auth/* add the strict tiers. Credential endpoints sit under a second, tighter rate limiter on top of the global one.
Source peek —
internal/server/server.gos.router.Use(mw.SecurityHeaders(isProd)) s.router.Use(mw.RequestID) s.router.Use(mw.RealIP(s.cfg.TrustedProxyCIDRs, s.cfg.ClientIPHeader)) // before the limiter s.router.Use(mw.RateLimiter(ctx, 50, 10)) // eviction stops with the server (#117) s.router.Use(mw.Compress(5)) s.router.Use(mw.Metrics) s.router.Use(mw.RequestLogger) s.router.Use(mw.Recoverer) s.router.Use(mw.Timeout(30 * time.Second)) s.router.Use(mw.CSRF(isProd)) r.Group(func(learn chi.Router) { learn.Use(mw.GuestSession(s.authClient, isProd)) // anonymous identity on first touch learn.Use(mw.OptionalAuth(s.authClient, isProd)) learn.Use(mw.OptionalUserLoader(userRepo)) learn.Use(mw.RateLimiter(ctx, 30.0/60.0, 20)) // stricter tier, anonymous-writable handler.QuizRoutes(learn, quizRepo) })ADR-014 Security patterns · ADR-027 trusted proxies ↗ Quiz yourself on routing →
Step 2: Handler resolves typed input
Handlers parse the form, validate at the boundary, call a repository interface, and hand a typed props struct to a templ view — one render path for pages and fragments.
A handler never sees SQL and never builds a map[string]interface{}. It reads its input, validates once at the system boundary, talks to a repository interface (the concrete Postgres type is injected), and constructs a typed props struct that the template was compiled against — a missing field is a build error, not a runtime surprise.
view.Render is the single render path. The same handler answers a full navigation with the page and an HTMX request with just the partial, decided by view.IsHTMXRequest — which is what makes progressive enhancement cheap: the page works as plain forms, HTMX upgrades it.
Source peek —
internal/handler/profile_handlers.gofunc ProfileUpdate(w http.ResponseWriter, r *http.Request) { name := strings.TrimSpace(r.FormValue("name")) if name == "" { /* 422 with a field error, partial or page */ } repo := webutil.GetUserRepoFromContext(r.Context()) // repository interface, not *pgxpool.Pool if _, err := repo.UpdateName(r.Context(), user.ID, name); err != nil { /* 500 */ } if view.IsHTMXRequest(r) { view.SetHXTrigger(w, "Profile updated successfully!") view.Render(w, r, http.StatusOK, partials.ProfileForm(partials.ProfileFormProps{Name: name, Success: true})) return } http.Redirect(w, r, "/profile", http.StatusSeeOther) }ADR-017 templ · ADR-012 routing & UI patterns ↗ Quiz yourself on handlers →
Step 3: Repository → sqlc → Postgres, scoped by RLS
Queries are declared in SQL and compiled by sqlc into typed Go; the repository runs each one inside a transaction that carries the request's JWT claims, so Row Level Security scopes every row to auth.uid().
Data access is a compile step, not string concatenation: sql/queries/*.sql is the source, sqlc generates the typed Go in internal/database, and the repository layer is the only thing that calls it. A regeneration-drift check keeps the generated code honest against its sources.
The tenant boundary lives in the database. The repository opens a transaction, sets the request's JWT claims as a local setting, and switches to the authenticated role — so users_self_access (auth_id = auth.uid()) is enforced by Postgres on every read and write. Anonymous guests are scoped by exactly the same policy as registered users: no parallel code path, no way for a handler to forget.
Source peek —
sql/queries/flashcards.sql-- name: ListFlashcardsByUser :many SELECT id, user_id, question_id, front, back, is_known, created_at, updated_at FROM flashcards WHERE user_id = $1 ORDER BY created_at DESC; -- migrations/000003_add_quiz_flashcards.up.sql ALTER TABLE flashcards ENABLE ROW LEVEL SECURITY; ALTER TABLE flashcards FORCE ROW LEVEL SECURITY; CREATE POLICY flashcards_self_access ON flashcards USING (EXISTS (SELECT 1 FROM users u WHERE u.id = flashcards.user_id AND u.auth_id = auth.uid()::text)) WITH CHECK (EXISTS (SELECT 1 FROM users u WHERE u.id = flashcards.user_id AND u.auth_id = auth.uid()::text));ADR-003 sqlc + repositories · ADR-004 RLS ↗ Quiz yourself on database →
Step 4: templ renders, HTMX swaps, Alpine sprinkles
Components are typed Go functions compiled from .templ files; HTMX requests fragments and swaps them in place; Alpine handles the light client-only interactivity. Role tokens, not raw colors, so dark mode flips variables instead of components.
templ compiles every template to Go, so props are structs and the view layer type-checks with the rest of the build. Pages compose a layout; partials render standalone so an HTMX fragment is exactly the markup the page would contain.
HTMX is the interaction model — hx-get/hx-post on plain elements, the server answering with HTML, swaps targeted by id — and every page must still work when it is not there (progressive enhancement). Alpine is reserved for client-only state like tabs and modals. Styling speaks role tokens (bg-surface, text-muted-foreground); a CI scan fails the build on raw palette colors or dark: variants.
Source peek —
internal/view/partials/quiz_question.templ// A standalone fragment for HTMX swaps AND a plain form without JS: // hx-post swaps the card in place, the form action posts the same payload full-page. templ QuizQuestionCard(props QuizQuestionProps) { <form method="post" action={ templ.SafeURL(quizAnswerAction(props.Slug)) } hx-post={ quizAnswerAction(props.Slug) } hx-target="#quiz-card" hx-swap="innerHTML" > @components.CSRFField() for i, choice := range props.Choices { <label class="flex items-center gap-3 p-3 rounded-md border border-border hover:border-primary"> <input type="radio" name="choice" value={ fmt.Sprintf("%d", i) } required/> <span>{ choice }</span> </label> } <button type="submit" class="btn btn-primary">Check answer</button> </form> }ADR-007 frontend stack · ADR-029 role tokens ↗ Quiz yourself on frontend →
Step 5: Performance budgets, enforced in CI and observed in production
Binary, memory, startup and gzipped JS/CSS budgets are constants in Go, gated by task ci — and the grid below puts what this very process has observed since boot next to each one, on every request.
ADR-000 states the budgets once, in internal/performance/budgets.go. task ci builds the stripped binary and measures it, gzips the shipped JS and CSS and measures those, and runs the budget tests with the race detector — so a regression is a red build, not a slow page someone notices later.
At runtime the same constants feed this page next to live readings: the metrics middleware records every request into a rolling window, the runtime reports memory, main records process start → listening, and the shipped assets and executable are measured once. A handler reads all of that and a templ component renders it — dynamic server-rendered content with zero client JavaScript. Every response also carries a Server-Timing header, so your browser's devtools show the handler time for this page. Prometheus at /metrics (bearer-gated in production) has the long-run view.
Source peek —
internal/performance/budgets.goconst ( MaxP95ResponseTime = 100 * time.Millisecond MaxP99ResponseTime = 200 * time.Millisecond MaxBinarySize = 20 * 1024 * 1024 // stripped linux build MaxMemoryUsage = 128 * 1024 * 1024 MaxStartupTime = 500 * time.Millisecond MaxJavaScriptSize = 50 * 1024 // gzipped MaxCSSSize = 30 * 1024 // gzipped ) // task ci → test:binary-size, test:asset-budgets, go test ./internal/performance/...ADR-000 performance budgets · ADR-021 quality gate ↗ Quiz yourself on performance →
Read it? Now prove it — wrong answers become flashcards you can keep.
Take the architecture quizLive performance budgets
Budget vs. observed, on this instance
Budgets are the constants in internal/performance/budgets.go — the same values task ci enforces. Observed values are read from this process on every request: latency over the recent window, memory from the runtime, startup from main, assets and binary as shipped.
Enforced fails the build · Monitored is measured but does not gate · Aspirational has no measurement yet (ADR-000 §1). This response carries a Server-Timing header too — open your devtools' timing tab.
- P50 response Monitored
- 100µs < 50mswithin budget median handler time · last 5 requests
- P95 response Monitored
- 1.9ms < 100mswithin budget budget tests + slow-request log · last 5 requests
- P99 response Monitored
- 1.9ms < 200mswithin budget tail latency ceiling · last 5 requests
- Binary size Enforced
- 14.8 MB < 20 MBwithin budget this executable; CI gates the stripped linux build
- Memory Monitored
- 13.1 MB < 128 MBwithin budget obtained from the OS by this process
- Peak memory Monitored
- 13.1 MB < 256 MBwithin budget high-water mark since boot
- Startup Monitored
- 21ms < 500mswithin budget process start to listening socket
- JavaScript Enforced
- 32.1 KB < 50 KBwithin budget gzipped — htmx + Alpine + app.js, as shipped
- CSS Enforced
- 7.2 KB < 30 KBwithin budget gzipped Tailwind build, as shipped
- Total page Aspirational
- — < 500 KBnot measured HTML + assets — no measurement exists yet (ADR-000 §1)