Forge Architecture: Go + HTMX + Svelte Islands
Status: Approved Date: December 2025 Supersedes: SvelteKit-only approach (see DECISION-LOG.md)
Executive Summary
Forge uses a hybrid architecture optimized for AI code generation:
- Core (80%): Go + HTMX + Templ — server-rendered HTML, minimal JS
- Islands (20%): Svelte components — for complex interactions that exceed HTML capabilities
This provides the simplest possible patterns for AI while offering an escape hatch for developers who need rich client-side interactions.
Architecture Diagram
┌─────────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ HTMX Shell │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │
│ │ │ Header │ │ Nav │ │ Content │ │ │
│ │ │ (HTML) │ │ (HTML) │ │ (HTML/HTMX) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────┐ ┌─────────────────────────┐ │ │
│ │ │ Svelte Island │ │ Svelte Island │ │ │
│ │ │ (Bracket Editor) │ │ (Seed Drag/Drop) │ │ │
│ │ └─────────────────────┘ └─────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
│ HTTP/SSE
▼
┌─────────────────────────────────────────────────────────────────┐
│ Go Server │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Router │ │ Handlers │ │ Templ Templates │ │
│ │ (stdlib) │ │ (HTML) │ │ (typed HTML) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ SSE Hub │ │ Services │ │ Database (Postgres) │ │
│ │ (realtime)│ │ (business)│ │ via sqlc │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Core Principles
1. Server is the Source of Truth
State lives in: Postgres → Go → HTML → Browser
Not in: Browser JS stores that sync with server
2. HTML is the API
// The response IS the UI. No JSON → JS → DOM translation.
func GetMatch(w http.ResponseWriter, r *http.Request) {
match := db.GetMatch(r.PathValue("id"))
templ.Render(w, MatchCard(match)) // Returns HTML
}
3. Islands are Exceptions, Not the Rule
Use Svelte islands ONLY when:
- Drag and drop is required
- Complex gesture handling
- Real-time collaborative editing
- Heavy client-side computation
- Rich data visualization
Do NOT use islands for:
- Forms (use HTML forms + HTMX)
- Lists (use HTMX + SSE)
- Navigation (use links + hx-boost)
- Modals (use HTMX + CSS)
- Tabs/accordions (use HTMX or CSS-only)
Technology Stack
Core (Required)
| Layer | Technology | Size | Purpose |
|---|---|---|---|
| Language | Go 1.23+ | N/A | Server runtime |
| Router | net/http (stdlib) | 0kb | HTTP routing |
| Templates | Templ | 0kb runtime | Type-safe HTML |
| Interactivity | HTMX 2.x | ~14kb | HTML-over-the-wire |
| Styling | Tailwind CSS 4 | ~10kb | Utility CSS |
| Database | PostgreSQL 16 | N/A | Primary data store |
| DB Access | sqlc | 0kb runtime | Type-safe SQL |
| Real-time | SSE (stdlib) | 0kb | Live updates |
Islands (When Needed)
| Layer | Technology | Size | Purpose |
|---|---|---|---|
| Framework | Svelte 5 | ~5kb per island | Reactive islands |
| Build | Vite | Dev only | Island compilation |
| Typing | TypeScript | Dev only | Island type safety |
Infrastructure
| Layer | Technology | Purpose |
|---|---|---|
| Deploy | Fly.io or Railway | Go hosting |
| Database | Supabase or Neon | Managed Postgres |
| CDN | Cloudflare | Static assets |
| Auth | Supabase Auth or custom | Session-based |
Project Structure
rally-hq/
├── cmd/
│ └── server/
│ └── main.go # Entry point
├── internal/
│ ├── handler/ # HTTP handlers
│ │ ├── tournament.go
│ │ ├── match.go
│ │ ├── team.go
│ │ └── sse.go # SSE broadcaster
│ ├── service/ # Business logic
│ │ ├── tournament.go
│ │ ├── bracket.go
│ │ └── scoring.go
│ ├── db/ # Database layer
│ │ ├── queries.sql # SQL queries
│ │ ├── queries.sql.go # Generated by sqlc
│ │ └── migrations/
│ └── middleware/
│ ├── auth.go
│ └── logging.go
├── templates/ # Templ templates
│ ├── layout.templ
│ ├── components/
│ │ ├── scoreboard.templ
│ │ ├── match_card.templ
│ │ ├── team_card.templ
│ │ └── bracket.templ # Static bracket (non-interactive)
│ └── pages/
│ ├── tournament.templ
│ ├── match.templ
│ └── admin.templ
├── islands/ # Svelte islands (separate build)
│ ├── package.json
│ ├── vite.config.ts
│ ├── src/
│ │ ├── BracketEditor.svelte # Interactive bracket
│ │ ├── SeedSorter.svelte # Drag-drop seeding
│ │ └── lib/
│ │ └── types.ts # Shared types
│ └── dist/ # Built islands (gitignored)
├── static/
│ ├── htmx.min.js # Vendored HTMX
│ ├── styles.css # Compiled Tailwind
│ └── islands/ # Compiled Svelte islands
├── sqlc.yaml
├── tailwind.config.js
├── Makefile
├── Dockerfile
└── go.mod
Island Architecture
How Islands Mount
<!-- In Templ template -->
<div id="bracket-editor"
data-island="BracketEditor"
data-props='{"tournamentId": "123", "teams": [...]}'>
<!-- Fallback content for no-JS / loading -->
<noscript>Interactive bracket requires JavaScript</noscript>
<div class="loading">Loading bracket editor...</div>
</div>
<script type="module">
// Island loader (tiny, ~500 bytes)
document.querySelectorAll('[data-island]').forEach(async (el) => {
const name = el.dataset.island;
const props = JSON.parse(el.dataset.props || '{}');
const { mount } = await import(`/static/islands/${name}.js`);
mount(el, props);
});
</script>
Island Component Pattern
<!-- islands/src/BracketEditor.svelte -->
<script lang="ts">
import type { Team, Match } from './lib/types';
interface Props {
tournamentId: string;
teams: Team[];
matches: Match[];
}
let { tournamentId, teams, matches }: Props = $props();
// Island manages its own state
let bracket = $state(buildBracket(teams, matches));
// Communicate changes back to server
async function saveMatch(matchId: string, winnerId: string) {
const response = await fetch(`/api/match/${matchId}/winner`, {
method: 'POST',
body: JSON.stringify({ winnerId }),
headers: { 'Content-Type': 'application/json' }
});
if (response.ok) {
// Update local state
bracket = updateBracket(bracket, matchId, winnerId);
// Trigger HTMX refresh of other elements if needed
htmx.trigger(document.body, 'bracket-updated');
}
}
</script>
<div class="bracket-editor">
{#each bracket.rounds as round, i}
<div class="round">
<h3>Round {i + 1}</h3>
{#each round.matches as match}
<MatchNode {match} onWinnerSelect={saveMatch} />
{/each}
</div>
{/each}
</div>
Island ↔ HTMX Communication
Islands can trigger HTMX updates:
// In island: trigger HTMX to refresh scoreboard
htmx.trigger('#scoreboard', 'refresh');
<!-- HTMX listens for island events -->
<div id="scoreboard"
hx-get="/tournament/123/scoreboard"
hx-trigger="refresh from:body">
<!-- Server-rendered scoreboard -->
</div>
HTMX can update island props:
<!-- HTMX response includes new island data -->
<div id="bracket-editor"
data-island="BracketEditor"
data-props='{"tournamentId": "123", "teams": [UPDATED]}'>
</div>
Data Flow Patterns
Pattern 1: Pure HTMX (80% of app)
User Action → HTMX Request → Go Handler → Templ → HTML Response → DOM Swap
<button hx-post="/match/123/score"
hx-vals='{"scoreA": 2, "scoreB": 1}'
hx-target="#match-123"
hx-swap="outerHTML">
Submit Score
</button>
Pattern 2: SSE Real-time (Live updates)
Server Event → SSE Broadcast → HTMX Receives → DOM Swap
<div hx-ext="sse" sse-connect="/tournament/123/live">
<div id="scoreboard" sse-swap="score-update">
<!-- Updated by SSE -->
</div>
</div>
Pattern 3: Island Interaction
User Action → Svelte State → fetch() → Go API → JSON → Svelte Update
↓
(optional) htmx.trigger() → HTMX Refresh
Pattern 4: Island + HTMX Hybrid
Initial: Go renders page with island placeholder + props
Island: Mounts, hydrates, becomes interactive
Save: Island POSTs to Go, triggers HTMX refresh of related content
Database Schema
-- migrations/001_initial.sql
CREATE TABLE tournaments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
game TEXT NOT NULL,
format TEXT NOT NULL CHECK (format IN ('single_elimination', 'double_elimination')),
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'registration', 'in_progress', 'completed')),
max_teams INTEGER NOT NULL DEFAULT 16,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
organizer_id UUID NOT NULL REFERENCES users(id)
);
CREATE TABLE teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tournament_id UUID NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE,
name TEXT NOT NULL,
seed INTEGER,
status TEXT NOT NULL DEFAULT 'registered' CHECK (status IN ('registered', 'checked_in', 'eliminated', 'winner')),
captain_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tournament_id, name)
);
CREATE TABLE matches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tournament_id UUID NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE,
round INTEGER NOT NULL,
position INTEGER NOT NULL,
team_a_id UUID REFERENCES teams(id),
team_b_id UUID REFERENCES teams(id),
winner_id UUID REFERENCES teams(id),
score_a INTEGER,
score_b INTEGER,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed')),
scheduled_at TIMESTAMPTZ,
UNIQUE(tournament_id, round, position)
);
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Indexes for common queries
CREATE INDEX idx_teams_tournament ON teams(tournament_id);
CREATE INDEX idx_matches_tournament ON matches(tournament_id);
CREATE INDEX idx_matches_status ON matches(status) WHERE status != 'completed';
Example: Complete Match Flow
1. View Match Page (HTMX)
// internal/handler/match.go
func (h *Handler) GetMatch(w http.ResponseWriter, r *http.Request) {
matchID := r.PathValue("id")
match, _ := h.db.GetMatchWithTeams(r.Context(), matchID)
templates.MatchPage(match).Render(r.Context(), w)
}
// templates/pages/match.templ
templ MatchPage(match db.MatchWithTeams) {
@Layout("Match") {
<div class="match-container">
@MatchCard(match)
<div hx-ext="sse" sse-connect={ fmt.Sprintf("/match/%s/live", match.ID) }>
<div id="score" sse-swap="score-update">
@ScoreDisplay(match)
</div>
</div>
if match.Status == "in_progress" {
@ScoreForm(match)
}
</div>
}
}
2. Submit Score (HTMX Form)
// templates/components/score_form.templ
templ ScoreForm(match db.MatchWithTeams) {
<form hx-post={ fmt.Sprintf("/match/%s/score", match.ID) }
hx-target="#score"
hx-swap="outerHTML"
class="score-form">
<div class="flex gap-4">
<input type="number" name="score_a"
value={ strconv.Itoa(match.ScoreA) }
class="w-20 text-center text-2xl" />
<span class="text-2xl">-</span>
<input type="number" name="score_b"
value={ strconv.Itoa(match.ScoreB) }
class="w-20 text-center text-2xl" />
</div>
<button type="submit" class="btn btn-primary">
Update Score
</button>
</form>
}
3. Handler Updates & Broadcasts
// internal/handler/match.go
func (h *Handler) PostScore(w http.ResponseWriter, r *http.Request) {
matchID := r.PathValue("id")
scoreA, _ := strconv.Atoi(r.FormValue("score_a"))
scoreB, _ := strconv.Atoi(r.FormValue("score_b"))
// Update database
match, _ := h.db.UpdateMatchScore(r.Context(), db.UpdateMatchScoreParams{
ID: matchID,
ScoreA: scoreA,
ScoreB: scoreB,
})
// Broadcast to spectators via SSE
html := templ.Render(templates.ScoreDisplay(match))
h.sse.Broadcast(matchID, "score-update", html)
// Return updated score for the admin
templates.ScoreDisplay(match).Render(r.Context(), w)
}
4. SSE Broadcaster
// internal/handler/sse.go
type SSEHub struct {
clients map[string]map[chan string]bool
mu sync.RWMutex
}
func (h *SSEHub) Subscribe(matchID string) chan string {
h.mu.Lock()
defer h.mu.Unlock()
ch := make(chan string, 10)
if h.clients[matchID] == nil {
h.clients[matchID] = make(map[chan string]bool)
}
h.clients[matchID][ch] = true
return ch
}
func (h *SSEHub) Broadcast(matchID, event, data string) {
h.mu.RLock()
defer h.mu.RUnlock()
for ch := range h.clients[matchID] {
select {
case ch <- fmt.Sprintf("event: %s\ndata: %s\n\n", event, data):
default:
// Client too slow, skip
}
}
}
func (h *Handler) MatchLiveStream(w http.ResponseWriter, r *http.Request) {
matchID := r.PathValue("id")
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
events := h.sse.Subscribe(matchID)
defer h.sse.Unsubscribe(matchID, events)
for {
select {
case event := <-events:
fmt.Fprint(w, event)
w.(http.Flusher).Flush()
case <-r.Context().Done():
return
}
}
}
Build & Deploy
Makefile
.PHONY: dev build deploy
# Development
dev:
@make -j2 dev-server dev-islands
dev-server:
air # Hot reload Go
dev-islands:
cd islands && npm run dev
# Build
build: build-islands build-server
build-islands:
cd islands && npm run build
cp -r islands/dist/* static/islands/
build-server:
templ generate
go build -o bin/server ./cmd/server
# Database
db-migrate:
goose -dir internal/db/migrations postgres "$$DATABASE_URL" up
db-generate:
sqlc generate
# Deploy
deploy: build
fly deploy
Dockerfile
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server ./cmd/server
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/server .
COPY --from=builder /app/static ./static
COPY --from=builder /app/templates ./templates
EXPOSE 8080
CMD ["./server"]
AI Code Generation Guidelines
For HTMX (Main App)
AI should generate:
// Handler: receive request, fetch data, render template
func GetTournament(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
t, _ := db.GetTournament(ctx, id)
templates.TournamentPage(t).Render(ctx, w)
}
// Template: HTML with HTMX attributes
templ TournamentPage(t Tournament) {
<div hx-get="/tournament/{t.ID}/matches" hx-trigger="load">
Loading matches...
</div>
}
For Islands (Escape Hatch)
AI should generate:
// Self-contained component with props
<script>
let { data, onSave } = $props();
let state = $state(data);
</script>
<div>
<!-- Interactive UI -->
</div>
Patterns to Avoid
// DON'T: Return JSON for UI
json.NewEncoder(w).Encode(data) // Only for islands
// DO: Return HTML
templates.Component(data).Render(ctx, w)
<!-- DON'T: Complex client state -->
<script>
const [state, setState] = useState(...) // Not in HTMX world
</script>
<!-- DO: Server state + HTMX -->
<div hx-get="/current-state" hx-trigger="every 5s">
Migration Path from SvelteKit
If porting existing SvelteKit code:
| SvelteKit Pattern | Go + HTMX Equivalent |
|---|---|
+page.svelte |
templates/pages/*.templ |
+page.server.ts (load) |
Go handler returning HTML |
+page.server.ts (actions) |
Go handler for POST |
| Svelte stores | SSE + HTMX swaps |
$effect() |
hx-trigger |
<form use:enhance> |
<form hx-post> |
| Complex component | Svelte island |
Success Metrics
Validate this architecture by measuring:
| Metric | Target | How to Measure |
|---|---|---|
| Initial page load | <100kb | Browser DevTools |
| Time to interactive | <1s | Lighthouse |
| AI code gen success | >85% | Log compile success rate |
| Island usage | <20% of pages | Code audit |
| SSE latency | <100ms | Performance monitoring |
Security Patterns
Authentication (Sessions)
// internal/middleware/auth.go
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := store.Get(r, "session")
if err != nil || session.Values["user_id"] == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Add user to context
ctx := context.WithValue(r.Context(), "user_id", session.Values["user_id"])
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Authorization (Row-Level Security)
For applications with runtime agents, push authorization into the database layer:
-- migrations/003_rls.sql
-- Enable RLS on protected tables
ALTER TABLE tournaments ENABLE ROW LEVEL SECURITY;
ALTER TABLE matches ENABLE ROW LEVEL SECURITY;
-- User access policy
CREATE POLICY user_tournament_access ON tournaments
FOR ALL
USING (
-- Owners can access their tournaments
owner_id = current_setting('app.user_id', true)::uuid
OR
-- Public tournaments visible to all
visibility = 'public'
);
-- Agent access policy (when runtime agents are used)
CREATE POLICY agent_tournament_access ON tournaments
FOR ALL
USING (
-- Check agent permission table
id IN (
SELECT tournament_id FROM agent_permissions
WHERE agent_id = current_setting('app.agent_id', true)::uuid
)
);
Go integration:
// internal/db/context.go
func (db *DB) WithUserContext(ctx context.Context, userID uuid.UUID) (*sql.Conn, error) {
conn, err := db.pool.Conn(ctx)
if err != nil {
return nil, err
}
_, err = conn.ExecContext(ctx,
"SELECT set_config('app.user_id', $1, true)",
userID.String())
if err != nil {
conn.Close()
return nil, err
}
return conn, nil
}
Why RLS over application-layer checks:
| Approach | Pros | Cons |
|---|---|---|
| Application-layer | Flexible, easy to test | Easy to forget, N+1 queries |
| Row-Level Security | Impossible to bypass, single source of truth | Requires Postgres, harder to test |
Forge recommends RLS for multi-tenant apps and apps with runtime agents.
CSRF Protection
// internal/middleware/csrf.go
func CSRFMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE" {
token := r.Header.Get("X-CSRF-Token")
if token == "" {
token = r.FormValue("csrf_token")
}
if !validateCSRFToken(r, token) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}
// Include in forms
templ CSRFInput(token string) {
<input type="hidden" name="csrf_token" value={ token } />
}
// Include in HTMX headers
templ CSRFMeta(token string) {
<meta name="csrf-token" content={ token } />
}
Input Validation
// internal/validation/tournament.go
type CreateTournamentInput struct {
Name string `validate:"required,min=3,max=100"`
Description string `validate:"max=1000"`
Format string `validate:"required,oneof=single_elimination double_elimination round_robin"`
MaxTeams int `validate:"required,min=2,max=128"`
}
func ValidateCreateTournament(r *http.Request) (*CreateTournamentInput, error) {
input := &CreateTournamentInput{
Name: r.FormValue("name"),
Description: r.FormValue("description"),
Format: r.FormValue("format"),
MaxTeams: parseInt(r.FormValue("max_teams")),
}
validate := validator.New()
if err := validate.Struct(input); err != nil {
return nil, err
}
return input, nil
}
For detailed agent-specific security patterns, see RUNTIME-AGENTS.md.
Next Steps
- Initialize Go project with structure above
- Set up Templ and create base layout
- Implement tournament CRUD (pure HTMX)
- Add SSE for live updates
- Create first Svelte island (bracket editor)
- Deploy to Fly.io
- Document patterns in LEARNINGS.md