Convention: SvelteKit SPA
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_URLat 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.authenticatedand 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.cssimported 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 inapp.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.tsor+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) |
Related
convention-frontend-css— CSS rules that apply to all frontendsproject-capacitor-mobile— Capacitor-specific details (iOS build pipeline, plugins, App Store)sop-frontend-experiment— how playground prototypes become SvelteKit appsfeedback_svelte_is_html— .svelte files ARE HTMLfeedback_no_tailwind— no Tailwind in pal-e-appfeedback_spa_no_subpath_proxy— SPAs can't be path-proxiedfeedback_local_dev_before_prod— local dev validation is mandatory