# 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}
open = false}>
{#if header}
{/if}
{#if content}
{@render content()}
{/if}
{#if footer}
{/if}
{/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
```
## 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}
```