# SvelteKit Web Guidelines ## Overview All web applications use **SvelteKit 2** with **Svelte 5** in runes mode. This guide covers component patterns, state management, routing, and API integration. ## Project Structure ``` apps/{project}/apps/web/ ├── src/ │ ├── app.html # HTML template │ ├── app.css # Global styles (Tailwind) │ ├── app.d.ts # Type declarations │ ├── hooks.server.ts # Server hooks (auth) │ ├── lib/ │ │ ├── components/ # Reusable components │ │ │ ├── ui/ # Generic UI components │ │ │ └── {feature}/ # Feature-specific components │ │ ├── stores/ # Svelte 5 stores (.svelte.ts) │ │ ├── api/ # API client │ │ ├── utils/ # Utilities │ │ └── types/ # TypeScript types │ └── routes/ │ ├── +layout.svelte # Root layout │ ├── +page.svelte # Home page │ ├── (auth)/ # Auth route group │ │ ├── login/ │ │ └── register/ │ └── (protected)/ # Protected route group │ ├── +layout.svelte │ ├── files/ │ └── settings/ ├── static/ # Static assets ├── svelte.config.js ├── vite.config.ts ├── tailwind.config.js └── package.json ``` ## Svelte 5 Runes ### State with $state ```svelte ``` ### Derived Values with $derived **CRITICAL: `$derived(expr)` vs `$derived.by(fn)`** - `$derived(expression)` — takes a **single expression**. The value IS the expression result. - `$derived.by(() => { ... return value; })` — takes a **function** (thunk). Use this when you need `if`/`switch`/`for` or multiple statements. **Common mistake:** writing `$derived(() => { ... })` — this stores the arrow function itself as the value, not its return value. Every `{#if myDerived}` will be truthy (functions are always truthy), and `myDerived()` will fail with "not callable" at the type level. ```svelte ``` ### Effects with $effect ```svelte ``` ### Props with $props ```svelte
onSelect?.(file)}> {file.name} {#if onDelete} {/if}
``` ### Bindable Props with $bindable ```svelte ``` ## Stores (Svelte 5 Pattern) ### Store File (.svelte.ts) ```typescript // src/lib/stores/files.svelte.ts import { browser } from '$app/environment'; import { api } from '$lib/api/client'; import type { File, AppError } from '$lib/types'; // Private state let files = $state([]); let loading = $state(false); let error = $state(null); let selectedId = $state(null); // Derived values const selectedFile = $derived(files.find((f) => f.id === selectedId) ?? null); const fileCount = $derived(files.length); // Actions async function loadFiles(folderId?: string): Promise { if (!browser) return; loading = true; error = null; const result = await api.files.list(folderId); if (result.ok) { files = result.data; } else { error = result.error; } loading = false; } async function deleteFile(id: string): Promise { const result = await api.files.delete(id); if (result.ok) { files = files.filter((f) => f.id !== id); if (selectedId === id) selectedId = null; return true; } error = result.error; return false; } function selectFile(id: string | null): void { selectedId = id; } function reset(): void { files = []; loading = false; error = null; selectedId = null; } // Export as object with getters export const fileStore = { // Getters for state get files() { return files; }, get loading() { return loading; }, get error() { return error; }, get selectedFile() { return selectedFile; }, get fileCount() { return fileCount; }, // Actions loadFiles, deleteFile, selectFile, reset, }; ``` ### Using Stores in Components ```svelte {#if fileStore.loading} {:else if fileStore.error} {:else} fileStore.selectFile(file.id)} onDelete={handleDelete} /> {/if} ``` ## API Client ```typescript // src/lib/api/client.ts import { browser } from '$app/environment'; import { goto } from '$app/navigation'; import { authStore } from '$lib/stores/auth.svelte'; import type { Result, AppError } from '@manacore/shared-errors'; import { ErrorCode } from '@manacore/shared-errors'; import { PUBLIC_BACKEND_URL } from '$env/static/public'; interface ApiResponse { ok: boolean; data?: T; error?: AppError; } async function request(endpoint: string, options: RequestInit = {}): Promise> { if (!browser) { return { ok: false, error: { code: ErrorCode.INTERNAL_ERROR, message: 'SSR not supported' } }; } try { const token = authStore.token; const response = await fetch(`${PUBLIC_BACKEND_URL}${endpoint}`, { ...options, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...options.headers, }, }); // Handle 401 - redirect to login if (response.status === 401) { authStore.logout(); goto('/login'); return { ok: false, error: { code: ErrorCode.UNAUTHORIZED, message: 'Session expired' } }; } const json: ApiResponse = await response.json(); if (!json.ok || json.error) { return { ok: false, error: json.error ?? { code: ErrorCode.UNKNOWN_ERROR, message: 'Request failed' }, }; } return { ok: true, data: json.data as T }; } catch (error) { return { ok: false, error: { code: ErrorCode.EXTERNAL_SERVICE_ERROR, message: 'Network error' }, }; } } // Typed API endpoints export const api = { files: { list: (folderId?: string) => request(`/api/v1/files${folderId ? `?folderId=${folderId}` : ''}`), get: (id: string) => request(`/api/v1/files/${id}`), create: (data: CreateFileDto) => request('/api/v1/files', { method: 'POST', body: JSON.stringify(data), }), update: (id: string, data: UpdateFileDto) => request(`/api/v1/files/${id}`, { method: 'PATCH', body: JSON.stringify(data), }), delete: (id: string) => request(`/api/v1/files/${id}`, { method: 'DELETE' }), }, folders: { list: () => request('/api/v1/folders'), get: (id: string) => request(`/api/v1/folders/${id}`), create: (data: CreateFolderDto) => request('/api/v1/folders', { method: 'POST', body: JSON.stringify(data), }), }, }; ``` ## Routing ### Route Groups ``` src/routes/ ├── +layout.svelte # Root layout (applies to all) ├── +page.svelte # / (home) ├── (auth)/ # Auth pages (no sidebar) │ ├── +layout.svelte # Auth layout │ ├── login/+page.svelte │ └── register/+page.svelte └── (app)/ # App pages (with sidebar) ├── +layout.svelte # App layout with auth check ├── files/ │ ├── +page.svelte # /files │ └── [id]/+page.svelte # /files/:id └── settings/+page.svelte ``` ### Layout with Auth Check ```svelte {#if authStore.isAuthenticated}
{@render children()}
{:else}
{/if} ``` ### Dynamic Routes ```svelte {#if loading} {:else if error} {:else if file} {/if} ``` ## Components ### Component Pattern ```svelte
e.key === 'Enter' && onSelect?.()} >

{file.name}

{formattedSize} • {formattedDate}

{#if onDelete} {/if}
``` ### Snippets (Slot Replacement) ```svelte {#snippet header()}

Confirm Delete

{/snippet} {#snippet content()}

Are you sure you want to delete this file?

{/snippet} {#snippet footer()} {/snippet}
{#if open} {/if} ``` ## Styling ### Tailwind Configuration ```javascript // tailwind.config.js import sharedConfig from '@manacore/shared-tailwind'; export default { presets: [sharedConfig], content: ['./src/**/*.{html,js,svelte,ts}'], theme: { extend: { // Project-specific overrides }, }, }; ``` ### Global Styles ```css /* src/app.css */ @import 'tailwindcss'; @import '@manacore/shared-tailwind/theme.css'; /* Custom utilities */ @layer utilities { .scrollbar-thin { scrollbar-width: thin; } } /* Custom components */ @layer components { .btn-primary { @apply px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors; } } ``` ## Form Handling ```svelte
{#if errors.form}
{errors.form}
{/if}
{#if errors.name} {errors.name} {/if}
{#if errors.email} {errors.email} {/if}
``` ## Environment Variables ### Build-Time vs Runtime Variables SvelteKit has **two types** of environment variables: 1. **Build-time** (`$env/static/public`) - Baked into the bundle at build time 2. **Runtime** (`process.env`) - Available at runtime in server code **CRITICAL**: For Docker deployments, browser-facing URLs must use **runtime injection** because: - Docker images are built once but deployed to different environments (staging, production) - Build-time variables would require rebuilding the image for each environment - The browser cannot access `process.env` - it needs values injected into the HTML ### ❌ WRONG - Hardcoded or Build-Time URLs ```typescript // ❌ BAD - Hardcoded URL (won't work in Docker) const MANA_AUTH_URL = 'http://localhost:3001'; // ❌ BAD - Build-time variable (works locally, breaks in Docker) import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public'; const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; // ❌ BAD - import.meta.env is also build-time const MANA_AUTH_URL = import.meta.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; ``` ### ✅ CORRECT - Runtime Injection Pattern **Step 1: Create `hooks.server.ts`** to inject env vars into HTML: ```typescript // src/hooks.server.ts import type { Handle } from '@sveltejs/kit'; // Get client-side URLs from Docker runtime environment const PUBLIC_MANA_CORE_AUTH_URL_CLIENT = process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || ''; const PUBLIC_BACKEND_URL_CLIENT = process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || ''; export const handle: Handle = async ({ event, resolve }) => { return resolve(event, { transformPageChunk: ({ html }) => { // Inject runtime environment variables into the HTML const envScript = ``; return html.replace('', `${envScript}`); }, }); }; ``` **Step 2: Read from `window` in client code:** ```typescript // src/lib/stores/auth.svelte.ts import { browser } from '$app/environment'; function getAuthUrl(): string { if (browser && typeof window !== 'undefined') { // Client-side: use injected window variable const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }) .__PUBLIC_MANA_CORE_AUTH_URL__; return injectedUrl || 'http://localhost:3001'; } // Server-side (SSR): use Docker internal URL return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; } // Use in auth service initialization const auth = initializeWebAuth({ baseUrl: getAuthUrl() }); ``` **Step 3: Set environment variables in `docker-compose.staging.yml`:** ```yaml services: myapp-web: environment: # Server-side URLs (Docker internal network) PUBLIC_BACKEND_URL: http://myapp-backend:3000 PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001 # Client-side URLs (browser access via public IP) PUBLIC_BACKEND_URL_CLIENT: https://myapp.mana.how:3000 PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://myapp.mana.how:3001 ``` ### Why Two URLs? | Variable | Purpose | Example | | ---------------------------------- | --------------------------------- | ----------------------------- | | `PUBLIC_MANA_CORE_AUTH_URL` | Server-to-server (SSR, API calls) | `http://mana-core-auth:3001` | | `PUBLIC_MANA_CORE_AUTH_URL_CLIENT` | Browser to server | `https://myapp.mana.how:3001` | Docker containers can reach each other by service name (`mana-core-auth`), but browsers need the public IP/domain. ### Apps Using This Pattern Correctly All web apps with backends now use the runtime injection pattern: - ✅ `chat/apps/web` - ✅ `picture/apps/web` - ✅ `quotes/apps/web` - ✅ `contacts/apps/web` - ✅ `calendar/apps/web` - ✅ `clock/apps/web` - ✅ `todo/apps/web` ### Apps That May Need Fixing - ❓ `cards/apps/web` - Check if using dynamic URLs - ❓ `manacore/apps/web` - Check if using dynamic URLs ### Quick Checklist for New SvelteKit Apps - [ ] Create `src/hooks.server.ts` with env injection - [ ] Update `auth.svelte.ts` to use `getAuthUrl()` pattern - [ ] Update `user-settings.svelte.ts` to use `getAuthUrl()` pattern - [ ] Update any feedback services to use runtime URL - [ ] Add both `_CLIENT` and non-client env vars to `docker-compose.staging.yml` - [ ] Never hardcode `localhost:3001` anywhere ### Simple .env (for local development only) ```env PUBLIC_BACKEND_URL=http://localhost:3016 PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001 ``` These work locally because both the browser and server access `localhost`. ## Anti-Patterns to Avoid ### Don't Use Old Svelte Syntax ```svelte ``` ### Don't Create Stores in Components ```svelte ``` ### Don't Fetch in Render ```svelte {#await promise}...{/await} ```