Frontend stack — SvelteKit, Tailwind, embedded in the Go binary
Context
Section titled “Context”Phases 1.1–1.11 built the Go backend with the legacy PHP as the temporary frontend (strangler-fig per ADR 0003). The plan was that the legacy backend would render against our Postgres + Go API until each feature got its own native UI.
That plan broke down. The recent Phase 1.11 work surfaced an open- ended series of MySQL-vs-Postgres semantic gaps in the legacy queries (strict GROUP BY, implicit type coercion, DISTINCT + ORDER BY rules). Every legacy page we exercise costs hours of patching, and every legacy upgrade we ever do reintroduces the gaps. The locked target architecture (per memory: “one Go binary, three containers, no sidecars, no new PHP”) already excludes PHP from prod — but we’d never set a hard date for the cutover.
The user’s decision: stop building against the legacy frontend.
Build our own UI to render the features we want. The legacy PHP
business-logic functions (do_search, save_resource_data, etc.)
remain available as a backend-gap-filler via a separate mechanism
(ADR 0015) — but the user-facing surface is ours.
This ADR locks the frontend stack, the integration with the Go binary, and the dev / prod build pipeline.
Decision
Section titled “Decision”| Layer | Choice | Rationale |
|---|---|---|
| Framework | SvelteKit 2 + Svelte 5 (runes) | Smallest bundles of mainstream frameworks; file-based routing; runes give predictable reactivity; small enough mental model that one person can own it |
| Language | TypeScript (strict) | Type-safe; shares the OpenAPI contract with Go via codegen |
| Styling | Tailwind CSS v4 | Image-grid + dark-mode + masonry are straightforward; @theme blocks + OKLCH palette gives clean token-driven theming |
| 3D | Threlte | Established Svelte binding for three.js, runes-compatible |
| Video / audio | Vidstack | Modern, framework-agnostic player with Svelte adapter; HLS / DASH support via hls.js when streaming lands |
| Icons | Lucide (Svelte port) | Already in use; visual consistency through the strangler-fig phase |
| API client | openapi-typescript + openapi-fetch | Generated from the same app/api/openapi.yaml that drives oapi-codegen on the Go side — one contract, two languages |
| Build | Vite (bundled with SvelteKit) | Standard |
Explicitly not adopted: React / Next.js (heavier, no benefit here), Redux / Pinia / Zustand (Svelte runes are sufficient), Tailwind UI / shadcn-svelte (defer until we hit a non-trivial primitive we don’t want to hand-roll).
Integration with the Go binary
Section titled “Integration with the Go binary”SvelteKit’s adapter-static outputs a fully prerendered static
bundle (web/build/) at build time. The Go binary embeds the bundle
via //go:embed (gated on the embed_web build tag) and serves it
at /. The same Go binary handles /api/v1/* via the existing
handler tree. Routing precedence: API and health probes win over
the static catch-all (SPA fallback), so unknown paths return the
SvelteKit index and the client router takes over.
┌─────────────────────── prod (one binary, three containers) ────────────┐│ ││ Browser → nginx → Go binary :8080 ││ │ ││ ├── /api/v1/* → Go handlers ││ ├── /healthz → Go handlers ││ └── /* → embedded SvelteKit bundle ││ │└────────────────────────────────────────────────────────────────────────┘
┌─────────────────────── dev (four containers, +1 for HMR) ──────────────┐│ ││ Browser → web :5173 (Vite + HMR) ││ │ ││ └── /api/* proxy to ───→ app :8080 → Go handlers ││ ││ Backend-only iterations skip the `web` profile and continue to use ││ the legacy UI at :8088 as before. ││ │└────────────────────────────────────────────────────────────────────────┘adapter-static was chosen over adapter-node because the latter
would require an additional Node container in prod, breaking the
locked three-container target. Per-request SSR is not load-bearing
for this app — image lazy-loading carries the perceived-perf win;
SEO surfaces (anonymous browse) can be served as a separate
prerendered route set if/when that matters.
Build pipeline
Section titled “Build pipeline”web/package.jsondeclares the SvelteKit project.npm installrunspostinstallwhich callssvelte-kit syncand (ifapp/api/openapi.yamlis present) regeneratesweb/src/lib/api/schema.d.tsviaopenapi-typescript.- The generated TS schema is not committed (gitignored). It’s
regenerated on every install and on every
openapi.yamlchange. This differs from the Go side where sqlc/oapi-codegen output IS committed — the difference: TS regen is cheap and contained to a postinstall hook, whereas Go source files need to be present forgo buildandgo testto pass on a fresh clone. - Prod build:
npm run buildproducesweb/build/. The app Dockerfile (extended in a follow-up sub-phase) copies that output toapp/internal/http/static_assets/and compiles Go with-tags embed_web. - Dev build: no
-tags, the Go binary skips static serving entirely (seestatic_dev.go); thewebdocker-compose service runs Vite on:5173with/api/*proxied to the Go binary.
Repo layout
Section titled “Repo layout”web/├── src/│ ├── routes/ # SvelteKit file-based routes│ ├── lib/│ │ ├── api/ # generated schema + thin openapi-fetch wrapper│ │ ├── components/ # hand-rolled UI primitives│ │ └── stores/ # runes-backed stores (theme, etc.)│ ├── app.html # HTML shell with no-FOUC theme init│ ├── app.css # Tailwind entrypoint + theme tokens│ └── app.d.ts # ambient types├── static/├── package.json├── svelte.config.js└── vite.config.ts
app/internal/http/├── static_dev.go # //go:build !embed_web — stub├── static_embed.go # //go:build embed_web — //go:embed└── static_assets/ # populated by Docker build (gitignored)Consequences
Section titled “Consequences”Wins
- The strict-GROUP-BY whack-a-mole stops. No more patching legacy HTML-rendering pages; we touch the legacy PHP only for the backend gap-fillers (ADR 0015), which return arrays we shape into JSON.
- Prod stays at one Go binary, three containers — the locked target architecture survives intact.
- Modern frontend stack (SvelteKit, Tailwind v4, Threlte, Vidstack) unlocks the multimedia features (3D model viewer, video playback) the legacy stack could never offer cleanly.
- Single contract: the OpenAPI spec drives both Go (
oapi-codegen) and TS (openapi-typescript). Drift between server and client is caught at compile time on both sides.
Costs
- A SvelteKit codebase to maintain. Several phases of frontend work (1.13.A–H) before users see the new UI light up.
- One extra dev container (the Vite HMR server). Backend-only days skip it via profile.
- Server-rendered HTML for first paint is lost. Mitigated by image lazy-loading + small JS bundle; SEO not load-bearing for an authenticated tool.
- New JS/TS toolchain in the build pipeline.
npmenters the CI story. Acceptable: it stays scoped toweb/, doesn’t leak into the Go module.
References
Section titled “References”- ADR 0003 — Strangler fig (the original “legacy backend as frontend” plan that this ADR retires)
- ADR 0015 — PHP as legacy backend (the companion piece — how the legacy business logic stays available without the legacy UI)
- Memory: “Target architecture — one Go binary, three containers” (locked 2026-05-24)