Project Doc

Forge Architecture: Go + HTMX + Svelte Islands

Full technical architecture (Go + HTMX + Svelte islands)

Updated: December 2025 Source: ARCHITECTURE.md

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

  1. Initialize Go project with structure above
  2. Set up Templ and create base layout
  3. Implement tournament CRUD (pure HTMX)
  4. Add SSE for live updates
  5. Create first Svelte island (bracket editor)
  6. Deploy to Fly.io
  7. Document patterns in LEARNINGS.md