Convention: SvelteKit SPA

convention-sveltekit-spa Convention

active convention

Rule

All SvelteKit SPAs follow the same architecture: adapter-static, keycloak-js PKCE, client-side fetch, nginx serving static files. No SSR. No server-side auth. No +page.server.ts files. Proven in mcd-tracker-app, adopted platform-wide.

Stack

Layer Technology Notes
Framework SvelteKit Vite-powered, adapter-static
Build adapter <code>@sveltejs/adapter-static</code> <code>fallback: 'index.html'</code> in svelte.config.js
Auth <code>keycloak-js</code> + PKCE Public client, no client secret, in-memory tokens
CSS Pure CSS vars + explicit styles Global <code>app.css</code> from playground. No Tailwind. See <code>convention-frontend-css</code>
Container <code>nginx:alpine</code> Serves static <code>build/</code> directory with SPA fallback

Build Configuration

svelte.config.js — the critical settings that make SPA mode work:
Setting Value Why
<code>adapter</code> <code>adapter-static({ fallback: 'index.html' })</code> SPA fallback — all routes resolve to index.html, SvelteKit router handles navigation client-side
<code>ssr</code> <code>false</code> No server-side rendering — everything runs in the browser
<code>bundleStrategy</code> <code>'single'</code> (recommended) Capacitor local server uses HTTP/1 — single bundle avoids waterfall

Authentication

SPA mode means no server-side auth. Auth.js requires adapter-node + SSR — incompatible. All SvelteKit SPAs use keycloak-js directly.

Keycloak Client Setup

Setting Value Rationale
Client type Public (Client authentication OFF) Can't store secrets client-side
PKCE Enabled (SHA256, keycloak-js default) Security for public clients
Token storage In-memory only Never localStorage — prevents hijacking
Token refresh <code>keycloak.updateToken(30)</code> before API calls 30-second buffer ensures fresh tokens
Silent check-SSO Hidden iframe (web), full check on resume (iOS) Platform-appropriate session check

Keycloak Initialization

Initialize in +layout.svelte via onMount. Auth guard blocks rendering until resolved.

Platform Detection (Capacitor)

Web and iOS use different redirect URIs. Detect at init time:
Platform Redirect URI Detection
Web <code>window.location.origin</code> Default
iOS (Capacitor) <code>capacitor://localhost/</code> <code>Capacitor.isNativePlatform()</code>
Local dev <code>http://localhost:5173</code> Must be in Keycloak redirect URIs

Environment Variables

Variable Purpose Example
<code>VITE_KEYCLOAK_URL</code> Keycloak server URL <code>https://keycloak.tail5b443a.ts.net</code>
<code>VITE_KEYCLOAK_REALM</code> Keycloak realm name <code>pal-e</code>
<code>VITE_KEYCLOAK_CLIENT_ID</code> Public client ID <code>mcd-tracker-app</code>
<code>VITE_API_URL</code> Backend API base URL <code>https://mcd-tracker.tail5b443a.ts.net</code>
VITE_ prefix is required — Vite only exposes prefixed env vars to client-side code.

Data Fetching

All data fetching is client-side. No +page.server.ts, no load() functions. Every API call goes through an authenticated fetch helper.

API Wrapper Pattern

Create src/lib/api.js (or api.ts) with an authenticated fetch helper:
Key rules:
  • Always call keycloak.updateToken(30) before every request
  • Use import.meta.env.VITE_API_URL — never hardcode URLs
  • Production fallback: set VITE_API_URL at build time or provide a default
  • No relative API paths — SPA has no server to proxy through

Routing

Client-side routing with auth guards. No server-side redirects.

Auth Guard Pattern

Implement in +layout.svelte:
  • Public routes allowlist — define routes that don't require auth (e.g., /, /about)
  • Auth check — if !keycloak.authenticated and route is not public, redirect to login
  • Role-based redirect — after auth, redirect users based on roles (admin → /admin, user → /dashboard)
Pattern:

CSS

Per convention-frontend-css:
  • Global app.css imported in +layout.svelte — copied directly from playground
  • Design tokens as CSS custom properties (var(--color-bg)), never hardcoded hex
  • No scoped <style> blocks in components — all styles live in app.css
  • No Tailwind — pure CSS vars + explicit styles (breaks playground-to-production copy-paste)
  • Mobile-first: if it scrolls horizontally on phone, it's broken

Dockerfile

All SvelteKit SPAs use the same Dockerfile pattern:

nginx.conf

Key points:
  • try_files $uri $uri/ /index.html — SPA fallback, all routes resolve to index.html
  • Cache headers on static assets — Vite hashes filenames, so immutable caching is safe
  • No server-side processing — nginx just serves files

Keycloak Redirect URI Checklist

Every SvelteKit SPA needs these configured in the Keycloak client:
Environment Redirect URI Web Origin
Production <code>https://{app}.tail5b443a.ts.net/*</code> <code>https://{app}.tail5b443a.ts.net</code>
iOS (Capacitor) <code>capacitor://localhost/*</code> <code>capacitor://localhost</code>
Local dev <code>http://localhost:5173/*</code> <code>http://localhost</code>

What NOT to Do

  • No +page.server.ts or +layout.server.ts — these require SSR
  • No adapter-node — that's for SSR apps (e.g., westside-contracts)
  • No Auth.js / NextAuth — requires server-side token exchange
  • No localStorage for tokens — security risk, use keycloak-js in-memory
  • No relative API paths — SPA has no server to proxy
  • No Tailwind — breaks playground copy-paste pipeline
  • No skipping local dev — if it doesn't work on localhost:5173, it doesn't get pushed

Proven In

App Repo Status
mcd-tracker-app <code>forgejo_admin/mcd-tracker-app</code> Production — full pattern implemented
pal-e-app <code>forgejo_admin/pal-e-app</code> Migrating (issues #51-#53)
  • convention-frontend-css — CSS rules that apply to all frontends
  • project-capacitor-mobile — Capacitor-specific details (iOS build pipeline, plugins, App Store)
  • sop-frontend-experiment — how playground prototypes become SvelteKit apps
  • feedback_svelte_is_html — .svelte files ARE HTML
  • feedback_no_tailwind — no Tailwind in pal-e-app
  • feedback_spa_no_subpath_proxy — SPAs can't be path-proxied
  • feedback_local_dev_before_prod — local dev validation is mandatory