chore: add agent skills for Svelte 5, SvelteKit, TypeScript, and Drizzle

Install project-level skills via skills.sh CLI:
- svelte5-best-practices (runes, snippets, events, migration)
- sveltekit-structure (routing, layouts, error boundaries)
- sveltekit-data-flow (load functions, form actions, serialization)
- typescript-best-practices (type-first development, discriminated unions)
- drizzle-migrations (SQLite schema, query patterns)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-06 14:11:36 +01:00
co-authored by Claude Opus 4.6
parent 0894c10c95
commit 02e6b57764
26 changed files with 5954 additions and 0 deletions
+423
View File
@@ -0,0 +1,423 @@
---
name: drizzle-migrations
description: Drizzle ORM schema management and SQLite migrations — adding tables, modifying columns, creating indexes, generating and running migrations, Drizzle query patterns. NOT for Prisma, TypeORM,
Sequelize, or raw SQL migration tools.
allowed-tools: Read,Write,Edit,Bash(npm:*,npx:*)
metadata:
category: Data & Analytics
tags:
- database
- drizzle
- migrations
pairs-with:
- skill: database-design-patterns
reason: Schema migration implementation follows database design pattern decisions
- skill: supabase-admin
reason: Drizzle migrations run against Supabase PostgreSQL databases with RLS considerations
- skill: fullstack-debugger
reason: Migration failures and schema mismatches are common fullstack debugging scenarios
---
# Drizzle ORM Migrations
This skill helps you manage database schema changes using Drizzle ORM with SQLite.
## When to Use
✅ **USE this skill for:**
- Adding new tables or modifying existing columns
- Generating and running database migrations
- Drizzle-specific query patterns and relations
- SQLite schema best practices with Drizzle
- Setting up Drizzle configuration
❌ **DO NOT use for:**
- Supabase/PostgreSQL → use `supabase-admin` skill
- Raw SQL without Drizzle → use standard SQL resources
- Prisma ORM → different syntax and patterns
- General database design theory → use database architecture resources
## Project Setup
**Configuration**: `drizzle.config.ts`
```typescript
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: './data/app.db',
},
});
```
**Commands**:
```bash
npm run db:generate # Generate migration files
npm run db:push # Push schema directly (dev only)
npm run db:studio # Open Drizzle Studio GUI
```
## Schema Definition
Location: `src/db/schema.ts`
### Table Definition
```typescript
import { sqliteTable, text, integer, real, blob } from 'drizzle-orm/sqlite-core';
import { relations } from 'drizzle-orm';
// Basic table
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
username: text('username').notNull(),
passwordHash: text('password_hash'),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at'),
});
// Table with foreign key
export const checkIns = sqliteTable('check_ins', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, {
onDelete: 'cascade',
}),
mood: integer('mood').notNull(),
cravingLevel: integer('craving_level').notNull(),
sleepHours: real('sleep_hours'),
notes: text('notes'),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
// Table with composite index
export const auditLog = sqliteTable('audit_log', {
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
action: text('action').notNull(),
targetType: text('target_type'),
targetId: text('target_id'),
details: text('details'), // JSON string
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
}, (table) => ({
userActionIdx: index('idx_audit_user_action').on(table.userId, table.action),
createdAtIdx: index('idx_audit_created').on(table.createdAt),
}));
```
### Relations
```typescript
export const usersRelations = relations(users, ({ many }) => ({
checkIns: many(checkIns),
sessions: many(sessions),
journalEntries: many(journalEntries),
}));
export const checkInsRelations = relations(checkIns, ({ one }) => ({
user: one(users, {
fields: [checkIns.userId],
references: [users.id],
}),
}));
```
## Column Types
### SQLite Types in Drizzle
```typescript
import {
sqliteTable,
text, // TEXT - strings, JSON, dates
integer, // INTEGER - numbers, booleans (0/1)
real, // REAL - floating point
blob, // BLOB - binary data
} from 'drizzle-orm/sqlite-core';
const examples = sqliteTable('examples', {
// Strings
name: text('name').notNull(),
description: text('description'),
// Numbers
count: integer('count').notNull().default(0),
rating: real('rating'),
// Booleans (stored as 0/1)
isActive: integer('is_active', { mode: 'boolean' }).default(true),
// Dates (stored as ISO strings)
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
expiresAt: text('expires_at'),
// JSON (stored as TEXT)
metadata: text('metadata', { mode: 'json' }),
// Enums (stored as TEXT)
status: text('status', { enum: ['pending', 'active', 'archived'] }),
});
```
## Migration Strategies
### Strategy 1: Push (Development Only)
```bash
npm run db:push
```
- Directly applies schema changes
- Fast for development
- **Never use in production**
### Strategy 2: Generate & Migrate (Production)
```bash
# 1. Generate migration file
npm run db:generate
# 2. Review generated SQL in /drizzle folder
# 3. Apply migration (in code or manually)
```
### Applying Migrations in Code
```typescript
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import Database from 'better-sqlite3';
const sqlite = new Database('./data/app.db');
const db = drizzle(sqlite);
// Run migrations
migrate(db, { migrationsFolder: './drizzle' });
```
## Common Schema Changes
### Adding a New Table
```typescript
// 1. Add to schema.ts
export const newFeature = sqliteTable('new_feature', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
name: text('name').notNull(),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
// 2. Add relations
export const newFeatureRelations = relations(newFeature, ({ one }) => ({
user: one(users, {
fields: [newFeature.userId],
references: [users.id],
}),
}));
// 3. Generate migration
// npm run db:generate
```
### Adding a Column
```typescript
// In schema.ts, add the new column
export const users = sqliteTable('users', {
// existing columns...
newColumn: text('new_column'), // Add this
});
// Generate migration
// npm run db:generate
```
### Adding an Index
```typescript
export const messages = sqliteTable('messages', {
id: text('id').primaryKey(),
conversationId: text('conversation_id').notNull(),
createdAt: text('created_at').notNull(),
}, (table) => ({
// Add index
convCreatedIdx: index('idx_messages_conv_created')
.on(table.conversationId, table.createdAt),
}));
```
### Renaming (Requires Manual SQL)
SQLite doesn't support direct column renames in older versions. For complex changes:
```sql
-- drizzle/XXXX_rename_column.sql
-- Manual migration for column rename
-- 1. Create new table with desired schema
CREATE TABLE users_new (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL, -- renamed from username
created_at TEXT NOT NULL
);
-- 2. Copy data
INSERT INTO users_new SELECT id, email, username, created_at FROM users;
-- 3. Drop old table
DROP TABLE users;
-- 4. Rename new table
ALTER TABLE users_new RENAME TO users;
```
## Query Patterns
### Basic Queries
```typescript
import { db } from '@/db';
import { eq, and, or, desc, asc, like, gte, lte } from 'drizzle-orm';
import { users, checkIns } from '@/db/schema';
// Select all
const allUsers = await db.select().from(users);
// Select with conditions
const activeUsers = await db
.select()
.from(users)
.where(eq(users.isActive, true));
// Select specific columns
const userEmails = await db
.select({ id: users.id, email: users.email })
.from(users);
// Complex where clause
const results = await db
.select()
.from(checkIns)
.where(
and(
eq(checkIns.userId, userId),
gte(checkIns.createdAt, startDate),
lte(checkIns.createdAt, endDate)
)
)
.orderBy(desc(checkIns.createdAt))
.limit(30);
```
### Insert
```typescript
// Single insert
const [newUser] = await db
.insert(users)
.values({
id: generateId(),
email: 'user@example.com',
username: 'newuser',
})
.returning();
// Bulk insert
await db.insert(checkIns).values([
{ id: '1', userId, mood: 7, cravingLevel: 2 },
{ id: '2', userId, mood: 8, cravingLevel: 1 },
]);
// Upsert (insert or update)
await db
.insert(users)
.values({ id: 'user-1', email: 'new@example.com' })
.onConflictDoUpdate({
target: users.id,
set: { email: 'new@example.com' },
});
```
### Update
```typescript
await db
.update(users)
.set({ username: 'newname', updatedAt: new Date().toISOString() })
.where(eq(users.id, userId));
```
### Delete
```typescript
// Always use WHERE clause!
await db
.delete(checkIns)
.where(eq(checkIns.id, checkInId));
// Delete with multiple conditions
await db
.delete(sessions)
.where(
and(
eq(sessions.userId, userId),
lte(sessions.expiresAt, new Date().toISOString())
)
);
```
### Joins
```typescript
const userWithCheckIns = await db
.select({
user: users,
checkIn: checkIns,
})
.from(users)
.leftJoin(checkIns, eq(users.id, checkIns.userId))
.where(eq(users.id, userId));
```
### Aggregations
```typescript
import { count, avg, sum, max, min } from 'drizzle-orm';
const stats = await db
.select({
totalCheckIns: count(),
avgMood: avg(checkIns.mood),
maxStreak: max(checkIns.streak),
})
.from(checkIns)
.where(eq(checkIns.userId, userId));
```
## Best Practices
1. **Always use transactions for related changes**
```typescript
await db.transaction(async (tx) => {
await tx.insert(users).values(userData);
await tx.insert(profiles).values(profileData);
});
```
2. **Always include WHERE on DELETE/UPDATE**
3. **Use indexes for frequently queried columns**
4. **Store dates as ISO strings for SQLite**
5. **Use `returning()` to get inserted/updated rows**
6. **Generate migrations, don't push to production**
## References
- [Drizzle ORM Docs](https://orm.drizzle.team/docs/overview)
- [Drizzle SQLite](https://orm.drizzle.team/docs/get-started-sqlite)
- [Drizzle Migrations](https://orm.drizzle.team/docs/migrations)
@@ -0,0 +1,81 @@
---
name: svelte5-best-practices
description: "Svelte 5 runes, snippets, SvelteKit patterns, and modern best practices for TypeScript and component development. Use when writing, reviewing, or refactoring Svelte 5 components and SvelteKit applications. Triggers on: Svelte components, runes ($state, $derived, $effect, $props, $bindable, $inspect), snippets ({#snippet}, {@render}), event handling, SvelteKit data loading, form actions, Svelte 4 to Svelte 5 migration, store to rune migration, slots to snippets migration, TypeScript props typing, generic components, SSR state isolation, performance optimization, or component testing."
license: MIT
metadata:
author: ejirocodes
version: '1.0.0'
---
# Svelte 5 Best Practices
## Quick Reference
| Topic | When to Use | Reference |
|-------|-------------|-----------|
| **Runes** | $state, $derived, $effect, $props, $bindable, $inspect | [runes.md](references/runes.md) |
| **Snippets** | Replacing slots, {#snippet}, {@render} | [snippets.md](references/snippets.md) |
| **Events** | onclick handlers, callback props, context API | [events.md](references/events.md) |
| **TypeScript** | Props typing, generic components | [typescript.md](references/typescript.md) |
| **Migration** | Svelte 4 to 5, stores to runes | [migration.md](references/migration.md) |
| **SvelteKit** | Load functions, form actions, SSR, page typing | [sveltekit.md](references/sveltekit.md) |
| **Performance** | Universal reactivity, avoiding over-reactivity, streaming | [performance.md](references/performance.md) |
## Essential Patterns
### Reactive State
```svelte
<script>
let count = $state(0); // Reactive state
let doubled = $derived(count * 2); // Computed value
</script>
```
### Component Props
```svelte
<script>
let { name, count = 0 } = $props();
let { value = $bindable() } = $props(); // Two-way binding
</script>
```
### Snippets (replacing slots)
```svelte
<script>
let { children, header } = $props();
</script>
{@render header?.()}
{@render children()}
```
### Event Handlers
```svelte
<!-- Svelte 5: use onclick, not on:click -->
<button onclick={() => count++}>Click</button>
```
### Callback Props (replacing createEventDispatcher)
```svelte
<script>
let { onclick } = $props();
</script>
<button onclick={() => onclick?.({ data })}>Click</button>
```
## Common Mistakes
1. **Using `let` without `$state`** - Variables are not reactive without `$state()`
2. **Using `$effect` for derived values** - Use `$derived` instead
3. **Using `on:click` syntax** - Use `onclick` in Svelte 5
4. **Using `createEventDispatcher`** - Use callback props instead
5. **Using `<slot>`** - Use snippets with `{@render}`
6. **Forgetting `$bindable()`** - Required for `bind:` to work
7. **Setting module-level state in SSR** - Causes cross-request leaks
8. **Sequential awaits in load functions** - Use `Promise.all` for parallel requests
@@ -0,0 +1,353 @@
# Svelte 5 Events Reference
## Table of Contents
- [Event Handler Syntax](#event-handler-syntax)
- [Callback Props Pattern](#callback-props-pattern)
- [Context API](#context-api)
---
## Event Handler Syntax
Svelte 5 replaces `on:click` directive syntax with standard HTML attribute syntax `onclick`.
### Basic Event Handlers
**Svelte 4:**
```svelte
<button on:click={handleClick}>Click</button>
<input on:input={handleInput} />
<form on:submit={handleSubmit}>...</form>
```
**Svelte 5:**
```svelte
<button onclick={handleClick}>Click</button>
<input oninput={handleInput} />
<form onsubmit={handleSubmit}>...</form>
```
### Event Modifiers Migration
Event modifiers no longer exist. Use wrapper functions:
**Svelte 4:**
```svelte
<form on:submit|preventDefault={handleSubmit}>...</form>
<button on:click|stopPropagation={handleClick}>...</button>
```
**Svelte 5:**
```svelte
<script>
function handleSubmit(event) {
event.preventDefault();
// ... handle form
}
function handleClick(event) {
event.stopPropagation();
// ... handle click
}
</script>
<form onsubmit={handleSubmit}>...</form>
<button onclick={handleClick}>...</button>
```
### Capture, Passive, and NonPassive
```svelte
<!-- Capture phase -->
<div onclickcapture={handleCapture}>
<button onclick={handleClick}>Click</button>
</div>
<!-- Passive listener -->
<div ontouchstartpassive={handleTouch}>...</div>
<!-- Non-passive -->
<div ontouchmovenonpassive={(e) => e.preventDefault()}>...</div>
```
### Inline Handlers
```svelte
<button onclick={() => count++}>Count: {count}</button>
<input oninput={(e) => name = e.target.value} />
```
### Event Handler Shorthand
```svelte
<script>
function onclick(event) {
console.log('Clicked!', event);
}
</script>
<button {onclick}>Click</button>
```
### Spreading Event Handlers
```svelte
<script>
let handlers = {
onclick: () => console.log('clicked'),
onmouseenter: () => console.log('entered'),
onmouseleave: () => console.log('left')
};
</script>
<button {...handlers}>Hover or Click</button>
```
### Multiple Handlers for Same Event
In Svelte 5, combine logic into one handler:
```svelte
<script>
function handleClick(event) {
handler1(event);
handler2(event);
}
</script>
<button onclick={handleClick}>...</button>
```
### TypeScript Event Typing
```svelte
<script lang="ts">
function handleClick(event: MouseEvent) {
console.log(event.clientX, event.clientY);
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
console.log(target.value);
}
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
const formData = new FormData(event.currentTarget as HTMLFormElement);
}
</script>
```
---
## Callback Props Pattern
Svelte 5 deprecates `createEventDispatcher` in favor of callback props for component-to-parent communication.
### Basic Event Pattern
**Svelte 4:**
```svelte
<!-- Button.svelte -->
<script>
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
</script>
<button on:click={() => dispatch('click', { timestamp: Date.now() })}>Click</button>
<!-- Parent.svelte -->
<Button on:click={(e) => console.log(e.detail)} />
```
**Svelte 5:**
```svelte
<!-- Button.svelte -->
<script>
let { onclick } = $props();
</script>
<button onclick={() => onclick?.({ timestamp: Date.now() })}>Click</button>
<!-- Parent.svelte -->
<Button onclick={(data) => console.log(data)} />
```
### Multiple Callbacks
```svelte
<!-- Dialog.svelte -->
<script>
let { onconfirm, oncancel, onclose } = $props();
</script>
<dialog>
<button onclick={() => onconfirm?.()}>Confirm</button>
<button onclick={() => oncancel?.()}>Cancel</button>
<button onclick={() => onclose?.()}>X</button>
</dialog>
<!-- Parent.svelte -->
<Dialog
onconfirm={() => save()}
oncancel={() => reset()}
onclose={() => visible = false}
/>
```
### Typed Callbacks with TypeScript
```svelte
<script lang="ts">
interface Props {
value?: string;
onsearch?: (query: string) => void;
onchange?: (value: string) => void;
onclear?: () => void;
}
let { value = '', onsearch, onchange, onclear }: Props = $props();
function handleInput(e: Event) {
const newValue = (e.target as HTMLInputElement).value;
value = newValue;
onchange?.(newValue);
}
</script>
```
### Forwarding Native Events
```svelte
<script lang="ts">
import type { MouseEventHandler } from 'svelte/elements';
interface Props {
onclick?: MouseEventHandler<HTMLButtonElement>;
children: import('svelte').Snippet;
}
let { onclick, children, ...rest }: Props = $props();
</script>
<button {onclick} {...rest}>{@render children()}</button>
```
---
## Context API
Context functions must be called synchronously during component initialization.
### Correct Context Usage
```svelte
<script>
import { setContext, getContext } from 'svelte';
// These run during initialization - CORRECT
setContext('theme', 'dark');
const theme = getContext('theme');
// INCORRECT - in $effect or callback
// $effect(() => { setContext('theme', 'dark'); }); // ERROR
</script>
```
### Reactive Context Values
Pass `$state` objects for reactive context:
```svelte
<!-- Provider.svelte -->
<script>
import { setContext } from 'svelte';
let theme = $state('light');
setContext('theme', {
get current() { return theme; },
toggle() { theme = theme === 'light' ? 'dark' : 'light'; }
});
let { children } = $props();
</script>
{@render children()}
<!-- Consumer.svelte -->
<script>
import { getContext } from 'svelte';
const themeContext = getContext('theme');
</script>
<p>Theme: {themeContext.current}</p>
<button onclick={themeContext.toggle}>Toggle</button>
```
### Type-Safe Context Pattern
```ts
// context.ts
import { setContext, getContext } from 'svelte';
const THEME_KEY = Symbol('theme');
interface ThemeContext {
current: 'light' | 'dark';
toggle: () => void;
}
export function setThemeContext(context: ThemeContext) {
setContext(THEME_KEY, context);
}
export function getThemeContext(): ThemeContext {
const context = getContext<ThemeContext>(THEME_KEY);
if (!context) throw new Error('Theme context not found');
return context;
}
```
### Avoid Generic Keys
```svelte
<script>
// Option 1: Symbol
const USER_KEY = Symbol('user');
setContext(USER_KEY, userData);
// Option 2: Unique string
setContext('myapp:user', userData);
// Option 3: Object key
const userKey = {};
setContext(userKey, userData);
</script>
```
### hasContext Check
```svelte
<script>
import { hasContext, getContext } from 'svelte';
const hasTheme = hasContext('theme');
const theme = hasTheme ? getContext('theme') : { current: 'light' };
</script>
```
### Context Boundaries
Context flows down the component tree:
```svelte
<!-- Parent.svelte -->
<script>
import { getContext, setContext } from 'svelte';
const level = getContext('level'); // 0
setContext('level', level + 1); // Override for children
</script>
<Child /> <!-- Will see level = 1 -->
```
@@ -0,0 +1,339 @@
# Svelte 4 to Svelte 5 Migration Reference
## Table of Contents
- [Migrating Reactive Statements](#migrating-reactive-statements)
- [Migrating Stores to Runes](#migrating-stores-to-runes)
---
## Migrating Reactive Statements
Svelte 5 replaces `$:` with `$derived` (for values) and `$effect` (for side effects).
### Computed Values: $: to $derived
**Svelte 4:**
```svelte
<script>
let count = 0;
$: doubled = count * 2;
$: quadrupled = doubled * 2;
</script>
```
**Svelte 5:**
```svelte
<script>
let count = $state(0);
let doubled = $derived(count * 2);
let quadrupled = $derived(doubled * 2);
</script>
```
### Complex Computed Values: $derived.by
**Svelte 4:**
```svelte
<script>
let items = [];
let filter = 'all';
$: filteredItems = {
if (filter === 'all') return items;
if (filter === 'active') return items.filter(i => !i.done);
return items.filter(i => i.done);
};
</script>
```
**Svelte 5:**
```svelte
<script>
let items = $state([]);
let filter = $state('all');
let filteredItems = $derived.by(() => {
if (filter === 'all') return items;
if (filter === 'active') return items.filter(i => !i.done);
return items.filter(i => i.done);
});
</script>
```
### Side Effects: $: to $effect
**Svelte 4:**
```svelte
<script>
let count = 0;
$: console.log('Count changed:', count);
$: document.title = `Count: ${count}`;
</script>
```
**Svelte 5:**
```svelte
<script>
let count = $state(0);
$effect(() => { console.log('Count changed:', count); });
$effect(() => { document.title = `Count: ${count}`; });
</script>
```
### Conditional Side Effects
**Svelte 4:**
```svelte
<script>
let value;
$: if (value > 100) { alert('Value too high!'); }
</script>
```
**Svelte 5:**
```svelte
<script>
let value = $state(0);
$effect(() => {
if (value > 100) { alert('Value too high!'); }
});
</script>
```
### Props Migration
**Svelte 4:**
```svelte
<script>
export let name;
export let count = 0;
$: greeting = `Hello, ${name}!`;
</script>
```
**Svelte 5:**
```svelte
<script>
let { name, count = 0 } = $props();
let greeting = $derived(`Hello, ${name}!`);
</script>
```
### Effect with Cleanup
**Svelte 4:**
```svelte
<script>
import { onDestroy } from 'svelte';
let count = 0;
let interval;
$: {
clearInterval(interval);
interval = setInterval(() => console.log(count), 1000);
}
onDestroy(() => clearInterval(interval));
</script>
```
**Svelte 5:**
```svelte
<script>
let count = $state(0);
$effect(() => {
const interval = setInterval(() => console.log(count), 1000);
return () => clearInterval(interval); // Cleanup built-in
});
</script>
```
### Migration Cheat Sheet
| Svelte 4 Pattern | Svelte 5 Replacement |
|------------------|----------------------|
| `let x = 0` (in component) | `let x = $state(0)` |
| `export let prop` | `let { prop } = $props()` |
| `$: derived = expr` | `let derived = $derived(expr)` |
| `$: { complex }` (value) | `let x = $derived.by(() => { ... })` |
| `$: console.log(x)` | `$effect(() => console.log(x))` |
| `$: if (x) { ... }` | `$effect(() => { if (x) { ... } })` |
| `$: document.title = x` | `$effect(() => { document.title = x })` |
### Automated Migration
```bash
npx sv migrate svelte-5
```
---
## Migrating Stores to Runes
Svelte 5 runes can replace most store use cases with simpler, more direct reactive state.
### Local Component State
**Svelte 4:**
```svelte
<script>
import { writable } from 'svelte/store';
const count = writable(0);
function increment() { count.update(n => n + 1); }
</script>
<button on:click={increment}>Count: {$count}</button>
```
**Svelte 5:**
```svelte
<script>
let count = $state(0);
function increment() { count++; }
</script>
<button onclick={increment}>Count: {count}</button>
```
### Shared State Across Components
**Svelte 4 (stores.ts):**
```ts
import { writable } from 'svelte/store';
export const user = writable(null);
export const theme = writable('light');
```
**Svelte 5 (state.svelte.ts):**
```ts
export const user = $state<User | null>(null);
export const theme = $state({ current: 'light' as 'light' | 'dark' });
export function setTheme(newTheme: 'light' | 'dark') {
theme.current = newTheme;
}
```
```svelte
<script>
import { theme, setTheme } from './state.svelte';
</script>
<p>Theme: {theme.current}</p>
<button onclick={() => setTheme('dark')}>Dark Mode</button>
```
### Derived Store to $derived
**Svelte 4:**
```ts
import { writable, derived } from 'svelte/store';
export const items = writable([]);
export const completedCount = derived(items, $items =>
$items.filter(i => i.done).length
);
```
**Svelte 5:**
```ts
// state.svelte.ts
export const items = $state<Item[]>([]);
export function getCompletedCount() {
return items.filter(i => i.done).length;
}
```
```svelte
<script>
import { items } from './state.svelte';
let completedCount = $derived(items.filter(i => i.done).length);
</script>
```
### Custom Store to Reactive Class
**Svelte 4:**
```ts
function createCounter() {
const { subscribe, set, update } = writable(0);
return {
subscribe,
increment: () => update(n => n + 1),
decrement: () => update(n => n - 1),
reset: () => set(0)
};
}
export const counter = createCounter();
```
**Svelte 5:**
```ts
// counter.svelte.ts
class Counter {
value = $state(0);
increment() { this.value++; }
decrement() { this.value--; }
reset() { this.value = 0; }
}
export const counter = new Counter();
```
```svelte
<script>
import { counter } from './counter.svelte';
</script>
<p>{counter.value}</p>
<button onclick={() => counter.increment()}>+</button>
```
### Async State Pattern
**Svelte 5:**
```ts
// api.svelte.ts
export const state = $state({
data: null as Data | null,
loading: false,
error: null as Error | null
});
export async function fetchData() {
state.loading = true;
state.error = null;
try {
const res = await fetch('/api/data');
state.data = await res.json();
} catch (e) {
state.error = e as Error;
} finally {
state.loading = false;
}
}
```
### When to Still Use Stores
1. **Library interop** - Libraries that expect Svelte stores
2. **Legacy code** - Gradual migration
3. **Observable patterns** - When you need the subscribe API
4. **Server-side rendering** - Stores have built-in SSR handling
### Store to Rune Cheat Sheet
| Store Pattern | Rune Replacement |
|---------------|------------------|
| `writable(value)` | `$state(value)` |
| `$store` (auto-subscribe) | Direct access to `$state` value |
| `store.set(x)` | `state = x` |
| `store.update(fn)` | Direct mutation or reassignment |
| `derived(store, fn)` | `$derived(fn())` in component |
| `readable(value, start)` | `$state` + `$effect` for setup |
@@ -0,0 +1,443 @@
# Svelte 5 Performance Reference
## Table of Contents
- [Universal Reactivity](#universal-reactivity)
- [Avoiding Over-Reactivity](#avoiding-over-reactivity)
- [Preventing Load Waterfalls](#preventing-load-waterfalls)
- [Streaming Non-Critical Data](#streaming-non-critical-data)
- [Component Testing](#component-testing)
---
## Universal Reactivity
Svelte 5 allows runes in `.svelte.js` or `.svelte.ts` files for shared state outside components.
### Shared Counter State
```ts
// counter.svelte.ts
export const counter = $state({ count: 0 });
export function increment() { counter.count++; }
export function decrement() { counter.count--; }
export function reset() { counter.count = 0; }
```
```svelte
<script>
import { counter, increment } from './counter.svelte';
</script>
<p>Count: {counter.count}</p>
<button onclick={increment}>+</button>
```
### Object State with Getters
```ts
// user.svelte.ts
const state = $state<User>({ firstName: '', lastName: '', email: '' });
export const user = {
get firstName() { return state.firstName; },
set firstName(v: string) { state.firstName = v; },
get fullName() { return `${state.firstName} ${state.lastName}`; },
get isValid() { return state.firstName && state.email.includes('@'); }
};
```
### Reactive Class Pattern
```ts
// todo.svelte.ts
class TodoStore {
items = $state<Todo[]>([]);
filter = $state<'all' | 'active' | 'completed'>('all');
get filtered() {
switch (this.filter) {
case 'active': return this.items.filter(t => !t.done);
case 'completed': return this.items.filter(t => t.done);
default: return this.items;
}
}
add(text: string) {
this.items.push({ id: Date.now(), text, done: false });
}
}
export const todos = new TodoStore();
```
### Important Notes
1. **File extension matters**: Must use `.svelte.js` or `.svelte.ts`
2. **No $derived in module scope**: Use getters for derived values
3. **SSR caution**: Avoid initializing browser-only state at module level
---
## Avoiding Over-Reactivity
Common mistakes with runes can cause unnecessary re-renders, infinite loops, or degraded performance.
### Anti-Pattern 1: Using $effect to Set Derived Values
**WRONG:**
```svelte
<script>
let count = $state(0);
let doubled = $state(0);
$effect(() => { doubled = count * 2; }); // Anti-pattern!
</script>
```
**CORRECT:**
```svelte
<script>
let count = $state(0);
let doubled = $derived(count * 2);
</script>
```
### Anti-Pattern 2: Circular Dependencies
**WRONG:**
```svelte
<script>
let a = $state(1);
let b = $state(2);
$effect(() => {
if (a > 10) b = 0;
if (b > 10) a = 0; // Triggers the effect again!
});
</script>
```
**CORRECT: Use separate effects or event handlers**
### Anti-Pattern 3: Not Using untrack
**WRONG:**
```svelte
<script>
let count = $state(0);
let log = $state([]);
$effect(() => {
log.push(`Count is ${count}`); // log change triggers re-run!
});
</script>
```
**CORRECT:**
```svelte
<script>
import { untrack } from 'svelte';
let count = $state(0);
let log = $state([]);
$effect(() => {
untrack(() => { log.push(`Count is ${count}`); });
});
</script>
```
### Anti-Pattern 4: Heavy Computations in $derived
**WRONG:**
```svelte
<script>
let items = $state([/* thousands */]);
let filter = $state('');
let filtered = $derived(
items.filter(item => JSON.stringify(item).includes(filter))
);
</script>
```
**CORRECT: Debounce expensive computations**
```svelte
<script>
let filter = $state('');
let debouncedFilter = $state('');
$effect(() => {
const timeout = setTimeout(() => { debouncedFilter = filter; }, 300);
return () => clearTimeout(timeout);
});
let filtered = $derived(
items.filter(item => item.name.includes(debouncedFilter))
);
</script>
```
### Anti-Pattern 5: Effect for DOM Manipulation
**WRONG:**
```svelte
<script>
let visible = $state(false);
let element;
$effect(() => {
if (element) element.style.display = visible ? 'block' : 'none';
});
</script>
```
**CORRECT:**
```svelte
{#if visible}<div>Content</div>{/if}
<!-- Or -->
<div class:hidden={!visible}>Content</div>
```
### Performance Tips
1. Use `$derived` over `$effect` for computed values
2. Debounce expensive reactive computations
3. Use `untrack` to prevent unnecessary dependencies
4. Group related state into objects
5. Let Svelte handle DOM updates
---
## Preventing Load Waterfalls
Sequential API calls multiply latency. Use parallel requests and streaming.
### Anti-Pattern: Waterfall
**WRONG (3 seconds total):**
```ts
export const load = async ({ fetch }) => {
const user = await fetch('/api/user').then(r => r.json()); // 1s
const posts = await fetch(`/api/users/${user.id}/posts`).then(r => r.json()); // 1s
const comments = await fetch('/api/comments').then(r => r.json()); // 1s
return { user, posts, comments };
};
```
### Pattern 1: Parallel with Promise.all
**CORRECT (1 second total):**
```ts
export const load = async ({ fetch }) => {
const [user, posts, comments] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/comments').then(r => r.json())
]);
return { user, posts, comments };
};
```
### Pattern 2: Partial Dependencies
```ts
export const load = async ({ fetch }) => {
const user = await fetch('/api/user').then(r => r.json());
const [posts, followers] = await Promise.all([
fetch(`/api/users/${user.id}/posts`).then(r => r.json()),
fetch(`/api/users/${user.id}/followers`).then(r => r.json())
]);
return { user, posts, followers };
};
```
### Pattern 3: Streaming Non-Critical Data
```ts
export const load = async ({ fetch }) => {
const user = await fetch('/api/user').then(r => r.json());
return {
user,
recommendations: fetch('/api/recommendations').then(r => r.json()),
analytics: fetch('/api/analytics').then(r => r.json())
};
};
```
### Performance Comparison
| Pattern | 3 APIs x 1s each |
|---------|------------------|
| Sequential | 3 seconds |
| Parallel (Promise.all) | 1 second |
| Streaming | 0s initial, streams rest |
---
## Streaming Non-Critical Data
Return promises from load functions to stream non-essential data after initial page render.
### Basic Streaming
**WRONG (blocks on slow data):**
```ts
export const load = async ({ fetch }) => {
const user = await fetch('/api/user').then(r => r.json()); // 100ms
const analytics = await fetch('/api/analytics').then(r => r.json()); // 2000ms
return { user, analytics }; // Page blocked for 2.1 seconds
};
```
**CORRECT (stream slow data):**
```ts
export const load = async ({ fetch }) => {
const user = await fetch('/api/user').then(r => r.json());
const analytics = fetch('/api/analytics').then(r => r.json()); // Don't await
return {
user, // Available in 100ms
analytics // Streams when ready
};
};
```
### Component Handling
```svelte
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<header><h1>Welcome, {data.user.name}</h1></header>
<aside>
{#await data.analytics}
<div class="skeleton">Loading analytics...</div>
{:then analytics}
<div class="analytics">
<p>Page views: {analytics.views}</p>
</div>
{:catch}
<div class="error">Failed to load analytics</div>
{/await}
</aside>
```
### When to Stream
| Data Type | Stream? | Reason |
|-----------|---------|--------|
| User info | No | Critical for layout |
| Main content | No | Users came for this |
| Analytics | Yes | Not user-facing |
| Recommendations | Yes | Supplementary |
| Comments | Maybe | Important but can load later |
---
## Component Testing
Svelte 5 components with runes require proper Vitest configuration.
### Option 1: Vitest Browser Mode (Recommended)
```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
test: {
browser: {
enabled: true,
provider: 'playwright',
name: 'chromium'
}
}
});
```
### Option 2: JSDOM with @testing-library/svelte
```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte({ hot: !process.env.VITEST })],
test: {
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,ts}'],
globals: true
}
});
```
### Basic Component Test
```ts
import { render, screen, fireEvent } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import Counter from './Counter.svelte';
test('increments count when clicked', async () => {
render(Counter);
const button = screen.getByRole('button');
expect(button).toHaveTextContent('Count: 0');
await fireEvent.click(button);
expect(button).toHaveTextContent('Count: 1');
});
```
### Testing Props
```ts
test('renders with custom name', () => {
render(Greeting, { props: { name: 'Alice' } });
expect(screen.getByText('Hello, Alice!')).toBeInTheDocument();
});
```
### Testing Callbacks
```ts
import { vi } from 'vitest';
test('calls onclick callback when clicked', async () => {
const handleClick = vi.fn();
render(Button, { props: { onclick: handleClick } });
await fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
```
### Testing Async Components
```ts
import { waitFor } from '@testing-library/svelte';
test('loads and displays user data', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ name: 'Alice' })
});
render(UserProfile, { props: { userId: '123' } });
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
});
```
@@ -0,0 +1,394 @@
# Svelte 5 Runes Reference
## Table of Contents
- [$state - Reactive State](#state---reactive-state)
- [$derived - Computed Values](#derived---computed-values)
- [$effect - Side Effects](#effect---side-effects)
- [$props - Component Props](#props---component-props)
- [$bindable - Two-Way Binding](#bindable---two-way-binding)
- [$inspect - Debugging](#inspect---debugging)
---
## $state - Reactive State
In Svelte 5, plain `let` declarations are no longer automatically reactive. Use `$state()` for reactive state.
### Basic Usage
```svelte
<script>
let count = $state(0);
</script>
<button onclick={() => count++}>
Clicks: {count}
</button>
```
### Object and Array State
Objects and arrays are deeply reactive by default:
```svelte
<script>
let user = $state({ name: 'Alice', age: 30 });
let items = $state(['apple', 'banana']);
</script>
<button onclick={() => user.age++}>Age: {user.age}</button>
<button onclick={() => items.push('cherry')}>Items: {items.length}</button>
```
### Class State
```svelte
<script>
class Counter {
count = $state(0);
increment() { this.count++; }
}
const counter = new Counter();
</script>
<button onclick={() => counter.increment()}>{counter.count}</button>
```
### Raw State (Opt-out of Deep Reactivity)
```svelte
<script>
let items = $state.raw([1, 2, 3]);
// This WON'T trigger an update:
items.push(4);
// This WILL trigger an update:
items = [...items, 4];
</script>
```
---
## $derived - Computed Values
Replaces `$:` reactive statements for computed values.
### Basic Usage
```svelte
<script>
let count = $state(0);
let doubled = $derived(count * 2);
let quadrupled = $derived(doubled * 2);
</script>
```
### Complex Derivations with $derived.by
```svelte
<script>
let items = $state([1, 2, 3, 4, 5]);
let filter = $state('even');
let filteredItems = $derived.by(() => {
if (filter === 'even') return items.filter(n => n % 2 === 0);
return items.filter(n => n % 2 !== 0);
});
</script>
```
### Deriving from Props
```svelte
<script>
let { firstName, lastName } = $props();
let fullName = $derived(`${firstName} ${lastName}`);
</script>
```
### $derived vs $effect
Use `$derived` for computing values, `$effect` for side effects:
```svelte
<script>
let count = $state(0);
// CORRECT: Use $derived for computed values
let doubled = $derived(count * 2);
// INCORRECT: Don't use $effect to set derived values
// let doubled;
// $effect(() => { doubled = count * 2; }); // Anti-pattern!
</script>
```
---
## $effect - Side Effects
Runs code when component mounts and when dependencies change. Requires cleanup for subscriptions.
### Basic Effect with Cleanup
```svelte
<script>
let count = $state(0);
$effect(() => {
const interval = setInterval(() => count++, 1000);
return () => clearInterval(interval); // Cleanup
});
</script>
```
### DOM Event Listeners
```svelte
<script>
let mouseX = $state(0);
let mouseY = $state(0);
$effect(() => {
function handleMouseMove(event) {
mouseX = event.clientX;
mouseY = event.clientY;
}
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
});
</script>
```
### External Subscriptions
```svelte
<script>
let { userId } = $props();
let userData = $state(null);
$effect(() => {
const unsubscribe = database.subscribe(`users/${userId}`, (data) => {
userData = data;
});
return () => unsubscribe();
});
</script>
```
### $effect.pre for Pre-DOM Updates
```svelte
<script>
let div;
let messages = $state([]);
$effect.pre(() => {
if (div) {
const shouldScroll = div.scrollTop + div.clientHeight >= div.scrollHeight - 20;
if (shouldScroll) {
tick().then(() => { div.scrollTop = div.scrollHeight; });
}
}
});
</script>
```
### Untracked Dependencies
```svelte
<script>
import { untrack } from 'svelte';
let count = $state(0);
let logCount = $state(0);
$effect(() => {
// Only runs when count changes, not logCount
console.log(count, untrack(() => logCount));
});
</script>
```
---
## $props - Component Props
Replaces `export let` for declaring component props.
### Basic Usage
```svelte
<script>
let { name, count = 0, disabled = false } = $props();
</script>
```
### Rest Props Pattern
```svelte
<script>
let { class: className, children, ...restProps } = $props();
</script>
<div class={className} {...restProps}>
{@render children?.()}
</div>
```
### Renaming Reserved Words
```svelte
<script>
let {
class: className, // 'class' is reserved
for: htmlFor, // 'for' is reserved
...rest
} = $props();
</script>
```
### TypeScript Props
```svelte
<script lang="ts">
interface Props {
name: string;
count?: number;
onClick?: (event: MouseEvent) => void;
}
let { name, count = 0, onClick }: Props = $props();
</script>
```
### Props with Children
```svelte
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
children: Snippet;
footer?: Snippet;
}
let { title, children, footer }: Props = $props();
</script>
<article>
<h1>{title}</h1>
<main>{@render children()}</main>
{#if footer}<footer>{@render footer()}</footer>{/if}
</article>
```
---
## $bindable - Two-Way Binding
Props must be explicitly marked with `$bindable()` to support `bind:`.
### Basic Usage
```svelte
<!-- Input.svelte -->
<script>
let { value = $bindable() } = $props();
</script>
<input bind:value />
<!-- Parent.svelte -->
<script>
import Input from './Input.svelte';
let name = $state('');
</script>
<Input bind:value={name} />
```
### Bindable with Default Value
```svelte
<script>
let { value = $bindable('default') } = $props();
</script>
```
### Multiple Bindable Props
```svelte
<script>
let {
value = $bindable(''),
checked = $bindable(false),
selected = $bindable(null)
} = $props();
</script>
```
### TypeScript with Bindable
```svelte
<script lang="ts">
interface Props {
value: string;
disabled?: boolean;
}
let { value = $bindable(''), disabled = false }: Props = $props();
</script>
```
---
## $inspect - Debugging
Development-only debugging that logs values when they change. Stripped in production.
### Basic Usage
```svelte
<script>
let count = $state(0);
$inspect(count); // Logs every time count changes
</script>
```
### Multiple Values
```svelte
<script>
let name = $state('Alice');
let age = $state(30);
$inspect(name, age);
</script>
```
### Custom Logging with $inspect.with
```svelte
<script>
let user = $state({ name: 'Alice', age: 30 });
$inspect.with((type, ...values) => {
if (type === 'init') console.log('Initial value:', values);
else console.log('Updated to:', values);
}, user);
</script>
```
### Debugging with Breakpoints
```svelte
<script>
let data = $state({ x: 0, y: 0 });
$inspect.with((type, value) => {
if (type === 'update' && value.x > 100) debugger;
}, data);
</script>
```
@@ -0,0 +1,291 @@
# Svelte 5 Snippets Reference
## Table of Contents
- [Replacing Slots with Snippets](#replacing-slots-with-snippets)
- [Rendering with @render](#rendering-with-render)
---
## Replacing Slots with Snippets
Svelte 5 replaces `<slot>` with snippets - a more powerful and type-safe composition primitive.
### Default Content (children)
**Svelte 4:**
```svelte
<div class="card"><slot /></div>
```
**Svelte 5:**
```svelte
<script>
let { children } = $props();
</script>
<div class="card">{@render children?.()}</div>
```
### Named Slots to Named Snippets
**Svelte 4:**
```svelte
<header><slot name="header" /></header>
<main><slot /></main>
<footer><slot name="footer" /></footer>
<!-- Usage -->
<Layout>
<h1 slot="header">Title</h1>
<p>Content</p>
<span slot="footer">Footer</span>
</Layout>
```
**Svelte 5:**
```svelte
<script>
let { header, children, footer } = $props();
</script>
<header>{@render header?.()}</header>
<main>{@render children?.()}</main>
<footer>{@render footer?.()}</footer>
<!-- Usage -->
<Layout>
{#snippet header()}<h1>Title</h1>{/snippet}
<p>Content</p>
{#snippet footer()}<span>Footer</span>{/snippet}
</Layout>
```
### Slot Props to Snippet Parameters
**Svelte 4:**
```svelte
<ul>
{#each items as item}
<li><slot {item} /></li>
{/each}
</ul>
<List {items} let:item>
<span>{item.name}</span>
</List>
```
**Svelte 5:**
```svelte
<script>
let { items, children } = $props();
</script>
<ul>
{#each items as item}
<li>{@render children?.(item)}</li>
{/each}
</ul>
<List {items}>
{#snippet children(item)}
<span>{item.name}</span>
{/snippet}
</List>
```
### Slot Fallback to Snippet Fallback
```svelte
<script>
let { children } = $props();
</script>
{#if children}
{@render children()}
{:else}
<p>Default content</p>
{/if}
```
### Defining Snippets Within Components
```svelte
<script>
let items = $state([
{ id: 1, name: 'Item 1', type: 'normal' },
{ id: 2, name: 'Item 2', type: 'featured' }
]);
</script>
{#snippet normalItem(item)}
<li>{item.name}</li>
{/snippet}
{#snippet featuredItem(item)}
<li class="featured">*** {item.name} ***</li>
{/snippet}
<ul>
{#each items as item}
{#if item.type === 'featured'}
{@render featuredItem(item)}
{:else}
{@render normalItem(item)}
{/if}
{/each}
</ul>
```
### TypeScript Typing
```svelte
<script lang="ts">
import type { Snippet } from 'svelte';
interface Item { id: number; name: string; }
interface Props {
header?: Snippet;
children: Snippet<[item: Item]>;
footer?: Snippet;
}
let { header, children, footer }: Props = $props();
</script>
```
---
## Rendering with @render
Snippets must be rendered using `{@render}`. Optional snippets need null-safe calling.
### Basic Snippet Rendering
```svelte
<script>
let { children } = $props();
</script>
<div>{@render children()}</div>
```
### Optional Snippets
Always use optional chaining for snippets that might not be provided:
```svelte
<script>
let { children } = $props();
</script>
{@render children?.()}
```
### Conditional Rendering
```svelte
<script>
let { header, children, footer } = $props();
</script>
{#if header}
<header>{@render header()}</header>
{/if}
<main>{@render children?.()}</main>
{#if footer}
<footer>{@render footer()}</footer>
{/if}
```
### Rendering with Arguments
```svelte
<script>
let { items, itemTemplate } = $props();
</script>
<ul>
{#each items as item, index}
<li>{@render itemTemplate(item, index)}</li>
{/each}
</ul>
<!-- Usage -->
<List {items}>
{#snippet itemTemplate(item, index)}
<span>{index + 1}. {item.name}</span>
{/snippet}
</List>
```
### Rendering Multiple Times
Snippets can be rendered multiple times:
```svelte
<script>
let { icon } = $props();
</script>
<button>
{@render icon?.()}
<span>Click me</span>
{@render icon?.()}
</button>
```
### Rendering Dynamic Snippets
```svelte
<script>
let { normalView, editView, isEditing } = $props();
let currentView = $derived(isEditing ? editView : normalView);
</script>
{@render currentView?.()}
```
### TypeScript with @render
```svelte
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
header?: Snippet;
row: Snippet<[data: { id: number; name: string }]>;
}
let { children, header, row }: Props = $props();
</script>
{@render header?.()}
{@render children()}
{@render row({ id: 1, name: 'Test' })}
```
### Passing Snippets to Child Components
```svelte
<!-- Wrapper.svelte -->
<script>
import Inner from './Inner.svelte';
let { itemRenderer } = $props();
</script>
<Inner {itemRenderer} />
<!-- Inner.svelte -->
<script>
let { itemRenderer } = $props();
</script>
{#each items as item}
{@render itemRenderer?.(item)}
{/each}
```
@@ -0,0 +1,401 @@
# SvelteKit Patterns Reference
## Table of Contents
- [Load Function Types](#load-function-types)
- [Page Props Typing](#page-props-typing)
- [Form Actions Error Handling](#form-actions-error-handling)
- [SSR State Isolation](#ssr-state-isolation)
---
## Load Function Types
SvelteKit has two load function types: universal (`+page.js`) and server-only (`+page.server.ts`).
### When to Use Each
| Use +page.server.ts | Use +page.js |
|---------------------|--------------|
| Accessing secrets/credentials | Public APIs only |
| Database connections | Non-serializable data (functions, classes) |
| Server-only APIs | Client-side caching benefits |
| Sensitive business logic | |
### Security: Secrets in Server Load
**WRONG (secrets exposed):**
```ts
// +page.js - DANGEROUS: runs in browser!
export const load = async ({ fetch }) => {
const response = await fetch('https://api.stripe.com/charges', {
headers: { 'Authorization': `Bearer ${STRIPE_SECRET_KEY}` } // EXPOSED!
});
return { charges: await response.json() };
};
```
**CORRECT:**
```ts
// +page.server.ts - only runs on server
import { STRIPE_SECRET_KEY } from '$env/static/private';
export const load = async ({ fetch }) => {
const response = await fetch('https://api.stripe.com/charges', {
headers: { 'Authorization': `Bearer ${STRIPE_SECRET_KEY}` }
});
return { charges: await response.json() };
};
```
### Serialization: Non-serializable in Universal Load
**WRONG (server load can't serialize functions):**
```ts
// +page.server.ts
export const load = async () => {
return {
formatDate: (date: Date) => date.toLocaleDateString(), // ERROR
parser: new DOMParser() // ERROR
};
};
```
**CORRECT:**
```ts
// +page.js
export const load = async () => {
return {
formatDate: (date: Date) => date.toLocaleDateString(), // OK
parser: typeof window !== 'undefined' ? new DOMParser() : null
};
};
```
### Database Access
```ts
// +page.server.ts - CORRECT
import { db } from '$lib/server/database';
export const load = async () => {
const users = await db.query('SELECT * FROM users');
return { users };
};
```
### Environment Variables
```ts
// +page.server.ts - Private env vars
import { DATABASE_URL, API_SECRET } from '$env/static/private';
// +page.js - Only public env vars
import { PUBLIC_API_URL } from '$env/static/public';
```
---
## Page Props Typing
SvelteKit generates types in `./$types` for full type safety with `$props()`.
### Basic Page Data Typing
```svelte
<!-- +page.svelte -->
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<h1>{data.title}</h1>
```
### Page Data with Form Actions
```svelte
<script lang="ts">
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
{#if form?.success}
<p class="success">{form.message}</p>
{/if}
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
```
### Layout Data Typing
```svelte
<!-- +layout.svelte -->
<script lang="ts">
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
</script>
<nav>
{#if data.user}<span>Welcome, {data.user.name}</span>{/if}
</nav>
{@render children()}
```
### Corresponding Load Function
```ts
// +page.server.ts
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async () => {
return {
title: 'My Page',
items: await fetchItems()
};
};
export const actions: Actions = {
default: async ({ request }) => {
return { success: true, message: 'Saved successfully' };
}
};
```
### Error Page
```svelte
<!-- +error.svelte -->
<script lang="ts">
import { page } from '$app/state';
</script>
<h1>{page.status}: {page.error?.message}</h1>
```
---
## Form Actions Error Handling
SvelteKit distinguishes between validation errors (`fail()`) and unexpected errors (`throw error()`).
### Use fail() for Validation Errors
**WRONG:**
```ts
import { error } from '@sveltejs/kit';
export const actions = {
default: async ({ request }) => {
if (!email?.toString().includes('@')) {
throw error(400, 'Invalid email'); // Shows error page!
}
}
};
```
**CORRECT:**
```ts
import { fail } from '@sveltejs/kit';
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get('email')?.toString() ?? '';
if (!email.includes('@')) {
return fail(400, {
error: 'Invalid email address',
email // Return for repopulation
});
}
return { success: true };
}
};
```
### Use throw error() for Unexpected Errors
```ts
import { fail, error } from '@sveltejs/kit';
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
// Validation - use fail()
if (!data.get('title')) {
return fail(400, { error: 'Title is required' });
}
try {
await database.save(data);
return { success: true };
} catch (e) {
// Unexpected error - use throw
throw error(500, 'Unable to save. Please try again later.');
}
}
};
```
### Structured Validation Responses
```ts
interface FormErrors {
email?: string;
password?: string;
}
export const actions = {
register: async ({ request }) => {
const data = await request.formData();
const errors: FormErrors = {};
if (!email) errors.email = 'Email is required';
if (password.length < 8) errors.password = 'Password must be at least 8 characters';
if (Object.keys(errors).length > 0) {
return fail(400, { errors, values: { email } });
}
return { success: true };
}
};
```
### Handle in Component
```svelte
<script lang="ts">
import type { PageProps } from './$types';
import { enhance } from '$app/forms';
let { form }: PageProps = $props();
</script>
<form method="POST" action="?/register" use:enhance>
<label>
Email
<input name="email" value={form?.values?.email ?? ''} />
{#if form?.errors?.email}
<span class="error">{form.errors.email}</span>
{/if}
</label>
<button type="submit">Register</button>
</form>
```
### Error Response Summary
| Situation | Function | Result |
|-----------|----------|--------|
| Missing field | `fail(400, {...})` | Form state preserved |
| Invalid format | `fail(400, {...})` | Form state preserved |
| Not found | `throw error(404, ...)` | Error page |
| Server crash | `throw error(500, ...)` | Error page |
| Auth required | `throw redirect(303, ...)` | Redirect |
---
## SSR State Isolation
Shared server state persists across requests, potentially leaking data between users.
### Dangerous: Module-Level State
**WRONG:**
```ts
// +page.server.ts
let currentUser = null; // SHARED ACROSS ALL REQUESTS!
export const load = async ({ locals }) => {
currentUser = locals.user; // User B overwrites User A
return { user: currentUser };
};
```
**CORRECT:**
```ts
export const load = async ({ locals }) => {
return { user: locals.user }; // Each request gets its own locals
};
```
### Dangerous: Global Stores
**WRONG:**
```ts
// stores.svelte.ts
export const user = $state<User | null>(null); // Server-side singleton!
```
**CORRECT: Use locals and return data from load**
```ts
// +layout.server.ts
export const load = async ({ locals }) => {
return { user: locals.user };
};
```
### Safe Patterns
**Pattern 1: Use locals**
```ts
// hooks.server.ts
export const handle = async ({ event, resolve }) => {
event.locals.user = await authenticate(event);
event.locals.requestId = crypto.randomUUID();
return resolve(event);
};
// +page.server.ts
export const load = async ({ locals }) => {
return { user: locals.user };
};
```
**Pattern 2: Context for Component Trees**
```svelte
<!-- +layout.svelte -->
<script lang="ts">
import { setContext } from 'svelte';
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
setContext('user', {
get current() { return data.user; }
});
</script>
{@render children()}
```
**Pattern 3: Client-Only State**
```ts
// stores.svelte.ts
import { browser } from '$app/environment';
function createClientStore() {
if (!browser) return { value: null };
const state = $state({ value: null });
return state;
}
export const clientState = createClientStore();
```
### SSR Safety Checklist
- [ ] No module-level `let` variables that store user data
- [ ] No global `$state` that gets set during SSR
- [ ] No singleton service classes with mutable state
- [ ] All user-specific data flows through `locals`
- [ ] All page data comes from load function returns
@@ -0,0 +1,305 @@
# Svelte 5 TypeScript Reference
## Table of Contents
- [Props Typing](#props-typing)
- [Generic Components](#generic-components)
---
## Props Typing
`$props()` requires specific patterns for TypeScript typing.
### Basic Props Typing
**Inline typing:**
```svelte
<script lang="ts">
let { name, count = 0 }: { name: string; count?: number } = $props();
</script>
```
**Interface typing (recommended):**
```svelte
<script lang="ts">
interface Props {
name: string;
count?: number;
disabled?: boolean;
}
let { name, count = 0, disabled = false }: Props = $props();
</script>
```
### Children and Snippets Typing
```svelte
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
children: Snippet; // Required children
header?: Snippet; // Optional snippet
row: Snippet<[data: RowData]>; // Snippet with parameter
cell?: Snippet<[value: string, index: number]>; // Multiple params
}
interface RowData { id: number; name: string; }
let { children, header, row, cell }: Props = $props();
</script>
```
### Callback Props Typing
```svelte
<script lang="ts">
interface Props {
value: string;
onchange?: (value: string) => void;
onsubmit?: (data: FormData) => Promise<void>;
onclick?: (event: MouseEvent) => void;
}
let { value, onchange, onsubmit, onclick }: Props = $props();
</script>
```
### Rest Props with HTML Attributes
```svelte
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
interface Props extends HTMLButtonAttributes {
variant?: 'primary' | 'secondary';
loading?: boolean;
}
let { variant = 'primary', loading = false, ...rest }: Props = $props();
</script>
<button class={variant} disabled={loading} {...rest}>
{#if loading}Loading...{:else}{@render children?.()}{/if}
</button>
```
### Input Element Props
```svelte
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements';
interface Props extends Omit<HTMLInputAttributes, 'value'> {
value?: string;
label: string;
error?: string;
}
let { value = $bindable(''), label, error, ...rest }: Props = $props();
</script>
```
### Union Type Props
```svelte
<script lang="ts">
type ButtonVariant = 'primary' | 'secondary' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
interface Props {
variant?: ButtonVariant;
size?: ButtonSize;
children: Snippet;
}
let { variant = 'primary', size = 'md', children }: Props = $props();
</script>
<button class="{variant} {size}">{@render children()}</button>
```
### Discriminated Union Props
```svelte
<script lang="ts">
type Props =
| { type: 'link'; href: string; children: Snippet }
| { type: 'button'; onclick: () => void; children: Snippet };
let props: Props = $props();
</script>
{#if props.type === 'link'}
<a href={props.href}>{@render props.children()}</a>
{:else}
<button onclick={props.onclick}>{@render props.children()}</button>
{/if}
```
---
## Generic Components
Svelte 5 uses the `generics` attribute for type-safe reusable components.
### Basic Generic Component
```svelte
<!-- List.svelte -->
<script lang="ts" generics="T">
interface Props {
items: T[];
children: import('svelte').Snippet<[item: T, index: number]>;
}
let { items, children }: Props = $props();
</script>
<ul>
{#each items as item, index}
<li>{@render children(item, index)}</li>
{/each}
</ul>
<!-- Usage -->
<script lang="ts">
import List from './List.svelte';
interface User { id: number; name: string; }
let users: User[] = $state([{ id: 1, name: 'Alice' }]);
</script>
<List items={users}>
{#snippet children(user, index)}
<span>{index + 1}. {user.name}</span> <!-- Fully typed! -->
{/snippet}
</List>
```
### Multiple Type Parameters
```svelte
<script lang="ts" generics="K, V">
interface Props {
entries: [K, V][];
renderKey: import('svelte').Snippet<[key: K]>;
renderValue: import('svelte').Snippet<[value: V]>;
}
let { entries, renderKey, renderValue }: Props = $props();
</script>
<dl>
{#each entries as [key, value]}
<dt>{@render renderKey(key)}</dt>
<dd>{@render renderValue(value)}</dd>
{/each}
</dl>
```
### Constrained Generics
```svelte
<script lang="ts" generics="T extends { id: string | number }">
interface Props {
items: T[];
selected?: T;
onselect?: (item: T) => void;
children: import('svelte').Snippet<[item: T, isSelected: boolean]>;
}
let { items, selected, onselect, children }: Props = $props();
</script>
{#each items as item}
<div
class:selected={selected?.id === item.id}
onclick={() => onselect?.(item)}
>
{@render children(item, selected?.id === item.id)}
</div>
{/each}
```
### Generic with Default Type
```svelte
<script lang="ts" generics="T = Record<string, unknown>">
interface Props {
data: T[];
columns: (keyof T)[];
}
let { data, columns }: Props = $props();
</script>
```
### Generic Select Component
```svelte
<script lang="ts" generics="T">
interface Props {
options: T[];
value?: T;
getLabel: (option: T) => string;
getValue: (option: T) => string;
onchange?: (selected: T | undefined) => void;
placeholder?: string;
}
let {
options,
value = $bindable(),
getLabel,
getValue,
onchange,
placeholder = 'Select...'
}: Props = $props();
function handleChange(e: Event) {
const selectedValue = (e.target as HTMLSelectElement).value;
const selected = options.find(o => getValue(o) === selectedValue);
value = selected;
onchange?.(selected);
}
</script>
<select onchange={handleChange}>
<option value="">{placeholder}</option>
{#each options as option}
<option
value={getValue(option)}
selected={value && getValue(value) === getValue(option)}
>
{getLabel(option)}
</option>
{/each}
</select>
```
### Generic Async Component
```svelte
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
interface Props {
promise: Promise<T>;
loading?: Snippet;
error?: Snippet<[error: Error]>;
children: Snippet<[data: T]>;
}
let { promise, loading, error, children }: Props = $props();
</script>
{#await promise}
{#if loading}{@render loading()}{:else}<p>Loading...</p>{/if}
{:then data}
{@render children(data)}
{:catch err}
{#if error}{@render error(err)}{:else}<p>Error: {err.message}</p>{/if}
{/await}
```
@@ -0,0 +1,17 @@
# Sveltekit Data Flow
SvelteKit data flow guidance. Use for load functions, form actions,
and server/client data. Covers +page.server.ts vs +page.ts,
serialization, fail(), redirect(), error().
## Structure
- `SKILL.md` - Main skill instructions
- `references/` - Detailed documentation loaded as needed
- `scripts/` - Executable code for deterministic operations
- `assets/` - Templates, images, or other resources
## Usage
This skill is automatically discovered by Claude when relevant to the
task.
@@ -0,0 +1,79 @@
---
name: sveltekit-data-flow
# IMPORTANT: Keep description on ONE line for Claude Code compatibility
# prettier-ignore
description: SvelteKit data flow guidance. Use for load functions, form actions, server/client data, and invalidation. Covers +page.server.ts vs +page.ts, serialization, fail(), redirect(), error(), invalidateAll().
---
# SvelteKit Data Flow
## Quick Start
**Which file?** Server-only (DB/secrets): `+page.server.ts` |
Universal (runs both): `+page.ts` | API: `+server.ts`
**Load decision:** Need server resources? → server load | Need client
APIs? → universal load
**Form actions:** Always `+page.server.ts`. Return `fail()` for
errors, throw `redirect()` to navigate, throw `error()` for failures.
## Example
```typescript
// +page.server.ts
import { fail, redirect } from '@sveltejs/kit';
export const load = async ({ locals }) => {
const user = await db.users.get(locals.userId);
return { user }; // Must be JSON-serializable
};
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get('email');
if (!email) return fail(400, { email, missing: true });
await updateEmail(email);
throw redirect(303, '/success');
},
};
```
## Reference Files
- [load-functions.md](references/load-functions.md) - Server vs
universal
- [form-actions.md](references/form-actions.md) - Form handling
patterns
- [serialization.md](references/serialization.md) - What can/can't
serialize
- [error-redirect-handling.md](references/error-redirect-handling.md) -
fail/redirect/error
- [client-auth-invalidation.md](references/client-auth-invalidation.md) -
invalidateAll() after client-side auth
## Notes
- Server load → universal load via `data` param | ALWAYS
`throw redirect()/error()`
- No class instances/functions from server load (not serializable)
- **Last verified:** 2025-01-11
<!--
PROGRESSIVE DISCLOSURE GUIDELINES:
- Keep this file ~50 lines total (max ~150 lines)
- Use 1-2 code blocks only (recommend 1)
- Keep description <200 chars for Level 1 efficiency
- Move detailed docs to references/ for Level 3 loading
- This is Level 2 - quick reference ONLY, not a manual
LLM WORKFLOW (when editing this file):
1. Write/edit SKILL.md
2. Format (if formatter available)
3. Run: claude-skills-cli validate <path>
4. If multi-line description warning: run claude-skills-cli doctor <path>
5. Validate again to confirm
-->
@@ -0,0 +1,125 @@
# Client-Side Auth Invalidation
When using client-side auth libraries (Better Auth, Firebase, Supabase client),
layout server data doesn't auto-refresh after login/logout.
## The Problem
```typescript
// signin/+page.svelte - BROKEN
async function handle_signin() {
await auth_client.signIn.email({ email, password });
goto('/'); // Layout still shows "logged out"
}
```
**Why?** Client-side navigation (`goto()`) doesn't re-run server load functions.
The session cookie is set, but `+layout.server.ts` data is stale.
## Solution A: Inline Invalidation (Simple)
Call `invalidateAll()` after auth state change:
```typescript
// signin/+page.svelte
import { goto, invalidateAll } from '$app/navigation';
async function handle_signin() {
const result = await auth_client.signIn.email({ email, password });
if (result.error) return;
await invalidateAll(); // Re-runs ALL load functions
goto('/');
}
// signout handler
async function handle_signout() {
await auth_client.signOut();
await invalidateAll();
}
```
**Use when:** Single auth entry point, simple apps.
## Solution B: Auth State Listener (Robust)
Set up a listener in root layout that auto-invalidates on auth changes:
```svelte
<!-- +layout.svelte -->
<script>
import { invalidateAll } from '$app/navigation';
import { onMount } from 'svelte';
import { auth_client } from '$lib/auth-client';
onMount(() => {
// Listen for auth state changes
const unsubscribe = auth_client.onAuthStateChange(() => {
invalidateAll();
});
return unsubscribe;
});
</script>
```
**Use when:** Multiple auth flows (OAuth, magic links, etc.), complex apps.
## Common Mistakes
### Not awaiting invalidateAll()
```typescript
// WRONG - race condition
invalidateAll();
goto('/');
// RIGHT - wait for data refresh
await invalidateAll();
goto('/');
```
### Destructuring layout data
```svelte
<!-- WRONG - static snapshot -->
<script>
let { data } = $props();
const { user } = data; // Never updates after invalidateAll()
</script>
<!-- RIGHT - reactive access -->
<script>
let { data } = $props();
</script>
{data.user?.email}
```
## When invalidateAll() Runs
Per [Svelte docs](https://svelte.dev/tutorial/kit/invalidate-all):
> `invalidateAll()` is the nuclear option - it re-runs ALL load functions
> for the current page, regardless of dependencies.
This includes:
- `+layout.server.ts` (all levels)
- `+layout.ts` (all levels)
- `+page.server.ts`
- `+page.ts`
## Comparison with Server-Side Auth
| Approach | Auth Location | Invalidation |
|----------|--------------|--------------|
| Form actions | Server (`+page.server.ts`) | Automatic (page reload) |
| Client auth | Browser (auth_client) | Manual (`invalidateAll()`) |
Form actions with `throw redirect()` cause a full navigation, which
naturally re-runs load functions. Client-side auth with `goto()` does not.
## See Also
- [error-redirect-handling.md](error-redirect-handling.md) - Server-side redirects
- [load-functions.md](load-functions.md) - Server vs universal loads
@@ -0,0 +1,407 @@
# Error & Redirect Handling: fail(), redirect(), error()
## The Three Ways to Handle Failures
| Function | When | Must Throw? | Use Case |
| ------------ | ---------------- | ----------- | ------------------------------------- |
| `fail()` | Validation error | No (return) | Form validation errors |
| `redirect()` | Navigate user | **YES** | After successful action |
| `error()` | Fatal error | **YES** | Unauthorized, not found, server error |
## fail() - Validation Errors
**Return** (don't throw) from form actions to show validation errors:
```typescript
import { fail } from '@sveltejs/kit';
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get('email');
// Validation failed - return fail()
if (!email || !email.includes('@')) {
return fail(400, {
email,
error: 'Invalid email',
missing: !email,
});
}
// Success - process and maybe redirect
await processEmail(email);
throw redirect(303, '/success');
},
};
```
```svelte
<!-- +page.svelte -->
<script>
export let form; // Contains fail() return value
</script>
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<form method="POST">
<input name="email" value={form?.email ?? ''} />
{#if form?.missing}
<span>Email is required</span>
{/if}
<button>Submit</button>
</form>
```
**Key points:**
- **Return** fail() (don't throw)
- Status code (400 = bad request)
- Return validation errors + form data to repopulate fields
- Accessible via `form` prop in page component
- Page stays on same URL
## redirect() - Navigation
**Throw** redirect() to navigate user to another page:
```typescript
import { redirect } from '@sveltejs/kit';
export const actions = {
login: async ({ request, cookies }) => {
const data = await request.formData();
const user = await authenticate(data);
if (!user) {
return fail(401, { error: 'Invalid credentials' });
}
cookies.set('session', user.sessionToken, { path: '/' });
// Success - redirect to dashboard
throw redirect(303, '/dashboard'); // MUST throw
},
};
```
**Status codes:**
- `303` - See Other (recommended for POST → GET redirect)
- `301` - Moved Permanently
- `302` - Found (temporary redirect)
- `307` - Temporary Redirect (preserves method)
- `308` - Permanent Redirect (preserves method)
**Use 303** for most cases (especially after form submission).
**Key points:**
- **MUST throw** (not return)
- Use status 303 for form actions
- Can redirect to external URLs
- Can use relative paths: `throw redirect(303, '..')`
## error() - Fatal Errors
**Throw** error() for unrecoverable errors (auth, not found, server
error):
```typescript
import { error } from '@sveltejs/kit';
export const load = async ({ params, locals }) => {
const post = await db.query.posts.findFirst({
where: eq(posts.id, params.id),
});
if (!post) {
throw error(404, 'Post not found'); // MUST throw
}
if (post.authorId !== locals.userId) {
throw error(403, 'Forbidden'); // MUST throw
}
return { post };
};
```
**Common status codes:**
- `400` - Bad Request
- `401` - Unauthorized (not logged in)
- `403` - Forbidden (logged in but no permission)
- `404` - Not Found
- `500` - Internal Server Error
**With custom error page:**
```typescript
// src/routes/posts/[id]/+page.server.ts
import { error } from '@sveltejs/kit';
export const load = async ({ params }) => {
const post = await getPost(params.id);
if (!post) {
throw error(404, {
message: 'Post not found',
postId: params.id,
});
}
return { post };
};
```
```svelte
<!-- src/routes/posts/[id]/+error.svelte -->
<script>
import { page } from '$app/stores';
</script>
<h1>{$page.status}: {$page.error.message}</h1>
{#if $page.error.postId}
<p>Could not find post with ID: {$page.error.postId}</p>
{/if}
```
**Key points:**
- **MUST throw** (not return)
- Renders closest `+error.svelte` file
- Accessible via `$page.status` and `$page.error`
- Stops load function execution
- Use for authorization, not found, server errors
## Common Mistakes
### ❌ Not Throwing redirect()
```typescript
// WRONG
export const actions = {
default: async () => {
redirect(303, '/home'); // DOESN'T WORK
},
};
// RIGHT
export const actions = {
default: async () => {
throw redirect(303, '/home'); // MUST throw
},
};
```
### ❌ Not Throwing error()
```typescript
// WRONG
export const load = async () => {
error(404, 'Not found'); // DOESN'T WORK
};
// RIGHT
export const load = async () => {
throw error(404, 'Not found'); // MUST throw
};
```
### ❌ Throwing fail()
```typescript
// WRONG
export const actions = {
default: async () => {
throw fail(400, { error: 'Bad' }); // Don't throw
},
};
// RIGHT
export const actions = {
default: async () => {
return fail(400, { error: 'Bad' }); // Return
},
};
```
### ❌ Catching redirect Without Rethrowing
```typescript
// WRONG
export const actions = {
default: async () => {
try {
await doSomething();
throw redirect(303, '/success');
} catch (e) {
console.error(e); // Catches redirect - it won't work!
return fail(500, { error: 'Failed' });
}
},
};
// RIGHT
import { isRedirect } from '@sveltejs/kit';
export const actions = {
default: async () => {
try {
await doSomething();
throw redirect(303, '/success');
} catch (e) {
if (isRedirect(e)) throw e; // Rethrow redirect
console.error(e);
return fail(500, { error: 'Failed' });
}
},
};
```
## Decision Tree
```
Problem in form action?
├─ Validation error (show to user) → return fail(400, { errors })
├─ Success (navigate) → throw redirect(303, '/success')
└─ Fatal error (auth, not found) → throw error(403, 'Forbidden')
Problem in load function?
├─ Data not found → throw error(404, 'Not found')
├─ Unauthorized → throw error(401, 'Unauthorized')
├─ Forbidden → throw error(403, 'Forbidden')
└─ Server error → throw error(500, 'Server error')
```
## Patterns
### Pattern 1: Validate, Process, Redirect
```typescript
export const actions = {
create: async ({ request }) => {
const data = await request.formData();
// 1. Validate
if (!isValid(data)) {
return fail(400, { errors: getErrors(data) });
}
// 2. Process
const post = await createPost(data);
// 3. Redirect
throw redirect(303, `/posts/${post.id}`);
},
};
```
### Pattern 2: Check Auth, Load Data
```typescript
export const load = async ({ locals, params }) => {
// 1. Check auth
if (!locals.user) {
throw error(401, 'Please log in');
}
// 2. Load data
const post = await getPost(params.id);
if (!post) {
throw error(404, 'Post not found');
}
// 3. Check permission
if (post.authorId !== locals.user.id) {
throw error(403, 'Not your post');
}
return { post };
};
```
### Pattern 3: Conditional Redirect
```typescript
export const load = async ({ locals }) => {
// Redirect if already logged in
if (locals.user) {
throw redirect(303, '/dashboard');
}
return {}; // Show login page
};
```
## Summary Table
| | fail() | redirect() | error() |
| -------------------- | ----------------- | --------------- | --------------- |
| **Throw or return?** | Return | **Throw** | **Throw** |
| **Use in** | Form actions | Actions & load | Actions & load |
| **Purpose** | Validation errors | Navigate | Fatal errors |
| **Status codes** | 400-499 | 301-308 | 400-599 |
| **Accessible via** | `form` prop | N/A (navigates) | `+error.svelte` |
| **Stays on page?** | Yes | No (navigates) | No (error page) |
| **Example** | Invalid email | After save | Not found |
## Client-Side Navigation After Auth
When using client-side auth (e.g., Better Auth client), **always use `goto()` with `invalidateAll: true`** to ensure layout data refreshes:
```svelte
<script>
import { goto } from '$app/navigation';
import { auth_client } from '$lib/auth-client';
async function handle_signin() {
const result = await auth_client.signIn.email({ email, password });
if (!result.error) {
// ✅ CORRECT: invalidateAll ensures layout load functions re-run
await goto('/dashboard', { invalidateAll: true });
}
}
</script>
```
### ❌ Common Mistake: Separate invalidateAll + goto
```typescript
// WRONG: Data might not refresh before navigation
await invalidateAll();
goto('/dashboard');
// ALSO WRONG: goto doesn't wait for invalidation
await invalidateAll();
await goto('/dashboard');
```
### ✅ Correct Pattern
```typescript
// RIGHT: Single call with invalidateAll option
await goto('/dashboard', { invalidateAll: true });
```
**Why this matters:** After client-side auth, cookies are set but the root layout's `load` function (which typically checks `auth.api.getSession()`) has cached data. Using `invalidateAll: true` forces all load functions to re-run with the new session cookie.
**Note:** Server-side redirects with `throw redirect()` don't have this problem because they trigger a full page load.
## Quick Checklist
- ✅ Using `return fail()` for validation errors?
- ✅ Using `throw redirect()` (not return) after success?
- ✅ Using `throw error()` (not return) for fatal errors?
- ✅ Not catching redirects/errors without rethrowing?
- ✅ Using status code 303 for POST redirects?
- ✅ Using `goto(url, { invalidateAll: true })` after client-side auth?
@@ -0,0 +1,316 @@
# Form Actions
## Basic Pattern
Form actions live in `+page.server.ts` and handle form submissions:
```typescript
// +page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get('email');
const password = data.get('password');
if (!email) {
return fail(400, { email, missing: true });
}
await login(email, password);
throw redirect(303, '/dashboard');
},
};
```
```svelte
<!-- +page.svelte -->
<script>
export let form; // Contains return value from action
</script>
<form method="POST">
<input name="email" value={form?.email ?? ''} />
{#if form?.missing}
<p class="error">Email is required</p>
{/if}
<button>Login</button>
</form>
```
## Named Actions
```typescript
export const actions: Actions = {
login: async ({ request }) => {
// Handle login
},
register: async ({ request }) => {
// Handle registration
},
};
```
```svelte
<form method="POST" action="?/login">...</form>
<form method="POST" action="?/register">...</form>
```
## Return Values
### Option 1: fail() - Show Error to User
```typescript
import { fail } from '@sveltejs/kit';
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
if (!isValid(data)) {
return fail(400, {
error: 'Invalid data',
fields: Object.fromEntries(data), // Preserve input
});
}
// Success - no return needed
},
};
```
### Option 2: redirect() - Navigate After Success
```typescript
import { redirect } from '@sveltejs/kit';
export const actions = {
default: async ({ request }) => {
await processForm(request);
throw redirect(303, '/success'); // MUST throw
},
};
```
### Option 3: error() - Fatal Error
```typescript
import { error } from '@sveltejs/kit';
export const actions = {
default: async ({ request }) => {
const user = await getCurrentUser();
if (!user) {
throw error(401, 'Unauthorized'); // MUST throw
}
// Process...
},
};
```
## Progressive Enhancement
Form works without JavaScript:
```svelte
<script>
import { enhance } from '$app/forms';
</script>
<form method="POST" use:enhance>
<!-- Works with or without JS -->
</form>
```
With custom handling:
```svelte
<form
method="POST"
use:enhance={({ formData, cancel }) => {
// Before submit
formData.append('timestamp', Date.now().toString());
return async ({ result, update }) => {
// After response
if (result.type === 'success') {
await update(); // Update form prop
}
};
}}
>
...
</form>
```
## Validation Pattern
```typescript
import { fail } from '@sveltejs/kit';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const rawData = {
email: data.get('email'),
password: data.get('password'),
};
const result = schema.safeParse(rawData);
if (!result.success) {
return fail(400, {
errors: result.error.flatten().fieldErrors,
data: rawData,
});
}
// result.data is validated
await createUser(result.data);
throw redirect(303, '/welcome');
},
};
```
## Common Mistakes
### ❌ Not Throwing redirect/error
```typescript
// WRONG
export const actions = {
default: async () => {
redirect(303, '/home'); // DOESN'T WORK - must throw
},
};
// RIGHT
export const actions = {
default: async () => {
throw redirect(303, '/home');
},
};
```
### ❌ Catching redirect Without Rethrowing
```typescript
// WRONG
export const actions = {
default: async () => {
try {
await doSomething();
throw redirect(303, '/success');
} catch (e) {
console.error(e); // Catches redirect!
}
},
};
// RIGHT
export const actions = {
default: async () => {
try {
await doSomething();
throw redirect(303, '/success');
} catch (e) {
if (e instanceof Redirect) throw e; // Rethrow
console.error(e);
}
},
};
```
### ❌ Returning non-serializable data
```typescript
// WRONG
export const actions = {
default: async () => {
return { date: new Date() }; // Date is not serializable
},
};
// RIGHT
export const actions = {
default: async () => {
return { date: new Date().toISOString() };
},
};
```
## File Upload
```typescript
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const file = data.get('file') as File;
if (!file || file.size === 0) {
return fail(400, { error: 'No file uploaded' });
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await saveFile(buffer, file.name);
throw redirect(303, '/uploads');
},
};
```
```svelte
<form method="POST" enctype="multipart/form-data">
<input type="file" name="file" required />
<button>Upload</button>
</form>
```
## Form Prop in Page
```svelte
<script>
export let data; // From load function
export let form; // From action return value
</script>
{#if form?.error}
<div class="error">{form.error}</div>
{/if}
{#if form?.success}
<div class="success">Success!</div>
{/if}
<form method="POST">
<input name="email" value={form?.email ?? data.email ?? ''} />
{#if form?.errors?.email}
<p>{form.errors.email}</p>
{/if}
<button>Submit</button>
</form>
```
## Key Rules
1. ✅ Actions must be in `+page.server.ts` (not `+page.ts`)
2. ✅ ALWAYS throw `redirect()` and `error()` (not return)
3. ✅ Return `fail()` for validation errors
4. ✅ Return only serializable data
5. ✅ Don't catch redirects/errors without rethrowing
6. ✅ Use `enhance` for progressive enhancement
7. ✅ Access FormData with `data.get('fieldName')`
@@ -0,0 +1,292 @@
# Load Functions: Server vs Universal
## Decision Matrix
| Need | Use | File |
| ----------------------------------- | -------------- | ----------------- |
| Database access | Server load | `+page.server.ts` |
| Secrets/env vars | Server load | `+page.server.ts` |
| Server-only packages | Server load | `+page.server.ts` |
| Browser APIs (window, localStorage) | Universal load | `+page.ts` |
| Client-side fetch | Universal load | `+page.ts` |
| Runs on both | Universal load | `+page.ts` |
## Server Load (+page.server.ts)
**When:** Need server-only resources (DB, secrets, server APIs)
**Runs:** Only on server (never in browser)
```typescript
// src/routes/profile/+page.server.ts
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/database';
export const load: PageServerLoad = async ({ locals, params }) => {
// Access server-only resources
const user = await db.query.users.findFirst({
where: eq(users.id, locals.userId),
});
const posts = await db.query.posts.findMany({
where: eq(posts.authorId, user.id),
});
// Must return serializable data
return {
user: {
id: user.id,
name: user.name,
email: user.email,
},
posts,
};
};
```
**Key points:**
- Runs only on server
- Can access `$lib/server/*` imports
- Can use secrets from `env` safely
- Return values must be JSON-serializable
- Output is automatically passed to universal load
## Universal Load (+page.ts)
**When:** Need to run on both server and client, or need browser APIs
**Runs:** Server (during SSR) AND client (during navigation)
```typescript
// src/routes/dashboard/+page.ts
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ data, fetch }) => {
// `data` comes from +page.server.ts if it exists
const { user } = data;
// Fetch additional data (works on both server and client)
const response = await fetch('/api/stats');
const stats = await response.json();
// Can access browser APIs (but check if in browser first)
const theme =
typeof window !== 'undefined'
? localStorage.getItem('theme')
: null;
return {
user,
stats,
theme,
};
};
```
**Key points:**
- Runs on both server AND client
- Receives server load output as `data` parameter
- Use SvelteKit's `fetch` (automatically handles SSR)
- Check `typeof window !== 'undefined'` for browser APIs
- Cannot import from `$lib/server/*`
## Data Flow
```
Request → Server Load (+page.server.ts)
↓ (returns { user })
Universal Load (+page.ts)
↓ (receives data: { user }, returns { user, stats })
Page Component (+page.svelte)
↓ (receives data: { user, stats })
```
**Example:**
```typescript
// +page.server.ts
export const load = async () => {
return { serverData: 'from server' };
};
// +page.ts
export const load = async ({ data }) => {
console.log(data.serverData); // 'from server'
return { ...data, clientData: 'from universal' };
};
// +page.svelte
<script>
export let data; // { serverData, clientData }
</script>
```
## Common Patterns
### Pattern 1: Server + Universal
```typescript
// +page.server.ts - Fetch sensitive data
export const load = async ({ locals }) => {
const user = await getUser(locals.session);
return { user };
};
// +page.ts - Fetch public data
export const load = async ({ data, fetch }) => {
const publicPosts = await fetch('/api/posts').then((r) => r.json());
return { ...data, publicPosts };
};
```
### Pattern 2: Conditional Universal Load
```typescript
// +page.ts
import { browser } from '$app/environment';
export const load = async ({ fetch }) => {
const serverData = await fetch('/api/data').then((r) => r.json());
// Only run in browser
let clientOnlyData = null;
if (browser) {
clientOnlyData = localStorage.getItem('cache');
}
return { serverData, clientOnlyData };
};
```
### Pattern 3: Depends for Revalidation
```typescript
// +page.ts
export const load = async ({ fetch, depends }) => {
depends('app:posts'); // Invalidate with invalidate('app:posts')
const posts = await fetch('/api/posts').then((r) => r.json());
return { posts };
};
// Somewhere else:
import { invalidate } from '$app/navigation';
invalidate('app:posts'); // Re-runs load function
```
## Common Mistakes
### ❌ Importing Server Code in Universal Load
```typescript
// +page.ts - WRONG
import { db } from '$lib/server/database'; // ERROR - can't import server code
export const load = async () => {
const users = await db.query.users.findMany(); // Won't work
return { users };
};
```
**Fix:** Move to `+page.server.ts`
### ❌ Returning Non-Serializable Data
```typescript
// +page.server.ts - WRONG
export const load = async () => {
const user = await User.findOne(); // Returns class instance
return { user }; // ERROR - class instances aren't serializable
};
```
**Fix:** Return plain objects
```typescript
export const load = async () => {
const user = await User.findOne();
return {
user: {
id: user.id,
name: user.name,
email: user.email,
},
};
};
```
### ❌ Using window/localStorage Without Check
```typescript
// +page.ts - WRONG
export const load = async () => {
const theme = localStorage.getItem('theme'); // ERROR on server
return { theme };
};
```
**Fix:** Check for browser
```typescript
import { browser } from '$app/environment';
export const load = async () => {
const theme = browser ? localStorage.getItem('theme') : 'light';
return { theme };
};
```
## TypeScript
```typescript
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({
locals,
params,
url,
}) => {
// TypeScript knows return type must be serializable
return {
user: {
id: 1,
name: 'Alice',
},
};
};
```
```typescript
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ data, fetch, params }) => {
// `data` is typed from +page.server.ts return type
return {
...data,
extra: 'data',
};
};
```
## When to Use Which
**Use Server Load when:**
- Need database access
- Need secrets/environment variables
- Need server-only npm packages
- Need to hide implementation details
**Use Universal Load when:**
- Need browser APIs (localStorage, window)
- Need to fetch public APIs (works on both)
- Want client-side navigation without server round-trip
- Data is public and doesn't need server resources
**Use Both when:**
- Server load fetches sensitive data
- Universal load fetches public data or adds client-side enhancements
@@ -0,0 +1,317 @@
# Serialization: What Can/Can't Be Returned
## The Rule
**Server load functions and form actions must return JSON-serializable
data.**
Data travels from server → client as JSON. Non-JSON types break.
## ✅ Serializable (Safe)
| Type | Example | Notes |
| ------------ | ------------------------- | --------------------------------- |
| String | `"hello"` | ✅ |
| Number | `42`, `3.14` | ✅ |
| Boolean | `true`, `false` | ✅ |
| null | `null` | ✅ |
| Array | `[1, 2, 3]` | ✅ |
| Plain Object | `{ name: 'Alice' }` | ✅ |
| Nested | `{ user: { posts: [] } }` | ✅ If all values are serializable |
## ❌ NOT Serializable (Breaks)
| Type | Example | Why | Fix |
| -------------- | -------------- | -------------------------- | -------------------------------------------- |
| Date | `new Date()` | Becomes string | Use `.toISOString()` |
| undefined | `undefined` | Removed from JSON | Use `null` |
| Function | `() => {}` | Can't serialize | Remove or convert to data |
| Class instance | `new User()` | Only serializes properties | Convert to plain object |
| Map | `new Map()` | Becomes `{}` | Convert to object: `Object.fromEntries(map)` |
| Set | `new Set()` | Becomes `{}` | Convert to array: `Array.from(set)` |
| BigInt | `123n` | Error | Convert to string |
| Symbol | `Symbol('id')` | Removed | Don't use |
| RegExp | `/test/` | Becomes `{}` | Convert to string |
| Error | `new Error()` | Loses stack | Extract message/code |
## Examples
### ❌ Wrong: Returning Date
```typescript
// +page.server.ts - WRONG
export const load = async () => {
return {
createdAt: new Date(), // Serializes as string, not Date object
};
};
```
```svelte
<!-- +page.svelte -->
<script>
export let data;
console.log(data.createdAt); // String, not Date
console.log(data.createdAt.getTime()); // ERROR - not a Date
</script>
```
### ✅ Right: Convert Date to ISO String
```typescript
// +page.server.ts - RIGHT
export const load = async () => {
const user = await db.users.findFirst();
return {
user: {
id: user.id,
name: user.name,
createdAt: user.createdAt.toISOString(), // Convert to string
},
};
};
```
```svelte
<!-- +page.svelte -->
<script>
export let data;
const createdAt = new Date(data.user.createdAt); // Parse back to Date
</script>
```
### ❌ Wrong: Returning Class Instance
```typescript
// +page.server.ts - WRONG
class User {
constructor(
public id: number,
public name: string,
) {}
getDisplayName() {
return `User: ${this.name}`;
}
}
export const load = async () => {
const user = new User(1, 'Alice');
return { user }; // Methods are lost during serialization
};
```
```svelte
<script>
export let data;
console.log(data.user.getDisplayName()); // ERROR - method doesn't exist
</script>
```
### ✅ Right: Return Plain Object
```typescript
// +page.server.ts - RIGHT
export const load = async () => {
const user = await db.users.findFirst();
return {
user: {
id: user.id,
name: user.name,
email: user.email,
// Only plain data, no methods
},
};
};
```
### ❌ Wrong: undefined Values
```typescript
// +page.server.ts - WRONG
export const load = async () => {
return {
name: 'Alice',
email: undefined, // Removed during JSON.stringify
};
};
```
```svelte
<script>
export let data;
console.log('email' in data); // false - key is missing!
</script>
```
### ✅ Right: Use null
```typescript
// +page.server.ts - RIGHT
export const load = async () => {
return {
name: 'Alice',
email: null, // Preserved
};
};
```
### ❌ Wrong: Map/Set
```typescript
// +page.server.ts - WRONG
export const load = async () => {
const tags = new Set(['svelte', 'typescript']);
const metadata = new Map([['version', '1.0']]);
return {
tags, // Becomes {}
metadata, // Becomes {}
};
};
```
### ✅ Right: Convert to Array/Object
```typescript
// +page.server.ts - RIGHT
export const load = async () => {
const tags = new Set(['svelte', 'typescript']);
const metadata = new Map([['version', '1.0']]);
return {
tags: Array.from(tags), // ['svelte', 'typescript']
metadata: Object.fromEntries(metadata), // { version: '1.0' }
};
};
```
## ORM Returns (Drizzle, Prisma)
Most ORMs return plain objects with Date fields:
```typescript
// +page.server.ts
export const load = async () => {
const user = await db.query.users.findFirst();
// user = { id: 1, name: 'Alice', createdAt: Date }
return {
user: {
...user,
createdAt: user.createdAt.toISOString(), // Convert Date
},
};
};
```
Or use a helper:
```typescript
function serialize<T extends Record<string, any>>(obj: T): T {
return JSON.parse(JSON.stringify(obj)); // Forces serialization
}
export const load = async () => {
const user = await db.query.users.findFirst();
return { user: serialize(user) };
};
```
## BigInt
```typescript
// +page.server.ts - WRONG
export const load = async () => {
return {
userId: 123456789012345678901234567890n, // ERROR - can't serialize
};
};
// +page.server.ts - RIGHT
export const load = async () => {
return {
userId: '123456789012345678901234567890', // String
};
};
```
## Detecting Serialization Issues
SvelteKit will throw an error if you try to return non-serializable
data:
```
Error: Data returned from `load` while rendering / is not serializable:
- Cannot stringify arbitrary non-POJOs
```
## Testing Serialization
```typescript
const data = { user: new User() };
try {
JSON.parse(JSON.stringify(data));
console.log('✅ Serializable');
} catch (e) {
console.log('❌ Not serializable');
}
```
## TypeScript Help
Use `satisfies` to catch issues at compile time:
```typescript
import type { PageServerLoad } from './$types';
export const load = (async () => {
return {
user: {
id: 1,
name: 'Alice',
createdAt: new Date(), // TypeScript allows this, but it's problematic
},
};
}) satisfies PageServerLoad;
```
Better: Create a type that only allows primitives:
```typescript
type Serializable =
| string
| number
| boolean
| null
| { [key: string]: Serializable }
| Serializable[];
export const load: PageServerLoad = async () => {
const data: Serializable = {
user: {
id: 1,
name: 'Alice',
createdAt: new Date().toISOString(), // Must be string
},
};
return data;
};
```
## Quick Checklist
Before returning from server load or form action:
1. ✅ All values are string, number, boolean, null, array, or plain
object?
2. ✅ No Date objects? (convert to `.toISOString()`)
3. ✅ No undefined? (use null)
4. ✅ No class instances? (convert to plain objects)
5. ✅ No Map/Set? (convert to object/array)
6. ✅ No functions?
7. ✅ No BigInt? (convert to string)
@@ -0,0 +1,17 @@
# Sveltekit Structure
SvelteKit structure guidance. Use for routing, layouts, error
handling, and SSR. Covers file naming (+page vs +layout vs +server),
nested layouts, error boundaries, and hydration.
## Structure
- `SKILL.md` - Main skill instructions
- `references/` - Detailed documentation loaded as needed
- `scripts/` - Executable code for deterministic operations
- `assets/` - Templates, images, or other resources
## Usage
This skill is automatically discovered by Claude when relevant to the
task.
@@ -0,0 +1,79 @@
---
name: sveltekit-structure
# IMPORTANT: Keep description on ONE line for Claude Code compatibility
# prettier-ignore
description: SvelteKit structure guidance. Use for routing, layouts, error handling, SSR, or svelte:boundary. Covers file naming, nested layouts, error boundaries, pending UI, and hydration.
---
# SvelteKit Structure
## Quick Start
**File types:** `+page.svelte` (page) | `+layout.svelte` (wrapper) |
`+error.svelte` (error boundary) | `+server.ts` (API endpoint)
**Routes:** `src/routes/about/+page.svelte` → `/about` |
`src/routes/posts/[id]/+page.svelte` → `/posts/123`
**Layouts:** Apply to all child routes. `+layout.svelte` at any level
wraps descendants.
## Example
```
src/routes/
├── +layout.svelte # Root layout (all pages)
├── +page.svelte # Homepage /
├── about/+page.svelte # /about
└── dashboard/
├── +layout.svelte # Dashboard layout (dashboard pages only)
├── +page.svelte # /dashboard
└── settings/+page.svelte # /dashboard/settings
```
```svelte
<!-- +layout.svelte -->
<script>
let { children } = $props();
</script>
<nav><!-- Navigation --></nav>
<main>{@render children()}</main>
<footer><!-- Footer --></footer>
```
## Reference Files
- [file-naming.md](references/file-naming.md) - File naming
conventions
- [layout-patterns.md](references/layout-patterns.md) - Nested layouts
- [error-handling.md](references/error-handling.md) - +error.svelte
placement
- [svelte-boundary.md](references/svelte-boundary.md) -
Component-level error/pending
- [ssr-hydration.md](references/ssr-hydration.md) - SSR and
browser-only code
## Notes
- Layouts: `{@render children()}` | Errors: +error.svelte _above_
failing route
- Groups: `(name)` folders don't affect URL | Client-only: check
`browser`
- **Last verified:** 2025-01-11
<!--
PROGRESSIVE DISCLOSURE GUIDELINES:
- Keep this file ~50 lines total (max ~150 lines)
- Use 1-2 code blocks only (recommend 1)
- Keep description <200 chars for Level 1 efficiency
- Move detailed docs to references/ for Level 3 loading
- This is Level 2 - quick reference ONLY, not a manual
LLM WORKFLOW (when editing this file):
1. Write/edit SKILL.md
2. Format (if formatter available)
3. Run: claude-skills-cli validate <path>
4. If multi-line description warning: run claude-skills-cli doctor <path>
5. Validate again to confirm
-->
@@ -0,0 +1,274 @@
# Error Handling
## Error Boundary Placement
**Key rule:** `+error.svelte` must be _above_ the failing route in the
hierarchy.
```
src/routes/
├── +error.svelte # Catches errors in all routes below
├── +page.svelte # If this errors → uses +error.svelte above
└── admin/
├── +error.svelte # Catches errors in admin routes
└── +page.svelte # If this errors → uses admin/+error.svelte
```
**Wrong:**
```
src/routes/dashboard/
├── +layout.svelte # If this errors...
└── +error.svelte # This won't catch it (too low)
```
**Right:**
```
src/routes/
├── +error.svelte # Catches dashboard layout errors
└── dashboard/
├── +layout.svelte
└── +error.svelte # Catches dashboard page errors
```
## Error Propagation
Errors bubble up to the nearest `+error.svelte`:
```
src/routes/
├── +error.svelte # Level 1 (root fallback)
└── blog/
├── +error.svelte # Level 2 (blog fallback)
└── [slug]/
├── +layout.server.ts # Error here → blog/+error.svelte
├── +page.server.ts # Error here → blog/+error.svelte
└── +page.svelte # Error here → blog/+error.svelte
```
**If no error boundary exists at that level, it goes to the parent.**
## Basic Error Page
```svelte
<!-- +error.svelte -->
<script>
import { page } from '$app/stores';
</script>
<h1>{$page.status}</h1><p>{$page.error.message}</p>
```
## Custom Error Data
```typescript
// +page.server.ts
import { error } from '@sveltejs/kit';
export const load = async ({ params }) => {
const post = await getPost(params.id);
if (!post) {
throw error(404, {
message: 'Post not found',
postId: params.id,
});
}
return { post };
};
```
```svelte
<!-- +error.svelte -->
<script>
import { page } from '$app/stores';
</script>
<h1>{$page.status}</h1>
<p>{$page.error.message}</p>
{#if $page.error.postId}
<p>Could not find post with ID: {$page.error.postId}</p>
{/if}
```
## Status Code Specific Errors
```svelte
<!-- +error.svelte -->
<script>
import { page } from '$app/stores';
</script>
{#if $page.status === 404}
<h1>Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<a href="/">Go home</a>
{:else if $page.status === 403}
<h1>Access Denied</h1>
<p>You don't have permission to view this page.</p>
{:else if $page.status === 401}
<h1>Unauthorized</h1>
<p>Please log in to continue.</p>
<a href="/login">Login</a>
{:else if $page.status >= 500}
<h1>Server Error</h1>
<p>Something went wrong on our end. Please try again later.</p>
{:else}
<h1>Error {$page.status}</h1>
<p>{$page.error.message}</p>
{/if}
```
## Common Status Codes
- **400** - Bad Request (malformed request)
- **401** - Unauthorized (not logged in)
- **403** - Forbidden (logged in but no permission)
- **404** - Not Found (resource doesn't exist)
- **500** - Internal Server Error (unhandled exception)
- **503** - Service Unavailable (server overloaded/down)
## Error in Layout vs Page
**Layout error:**
```typescript
// src/routes/dashboard/+layout.server.ts
export const load = async ({ locals }) => {
if (!locals.user) {
throw error(401, 'Not logged in'); // Caught by +error.svelte ABOVE dashboard
}
return { user: locals.user };
};
```
**Page error:**
```typescript
// src/routes/dashboard/settings/+page.server.ts
export const load = async () => {
throw error(404, 'Settings not found'); // Caught by nearest +error.svelte
};
```
## Expected Errors vs Unexpected Errors
**Expected (use error()):**
```typescript
// User requests invalid resource
if (!post) throw error(404, 'Post not found');
// User lacks permission
if (post.authorId !== user.id) throw error(403, 'Not your post');
```
**Unexpected (let it bubble):**
```typescript
// Unhandled exceptions (DB connection fails, etc.)
// Will show generic 500 error
const posts = await db.query.posts.findMany(); // Might throw
```
## Handling Errors in handleError Hook
```typescript
// src/hooks.server.ts
import type { HandleServerError } from '@sveltejs/kit';
export const handleError: HandleServerError = ({ error, event }) => {
// Log to error tracking service
console.error('Error:', error, 'Path:', event.url.pathname);
// Return user-friendly message (don't expose internals)
return {
message: 'An unexpected error occurred',
code: error?.code ?? 'UNKNOWN',
};
};
```
## Error in Load vs Error in Component
**Load function error (recommended):**
```typescript
// +page.server.ts
export const load = async ({ params }) => {
const user = await getUser(params.id);
if (!user) throw error(404, 'User not found'); // Shows +error.svelte
return { user };
};
```
**Component error (not ideal):**
```svelte
<!-- +page.svelte - Don't do this -->
<script>
export let data;
// If data.user is undefined, component just shows nothing
// No error boundary triggered
</script>
<h1>{data.user.name}</h1> <!-- Might crash if user undefined -->
```
**Best practice:** Validate and throw errors in `load` functions, not
components.
## Fallback Error Handling
Always have a root `+error.svelte`:
```svelte
<!-- src/routes/+error.svelte -->
<script>
import { page } from '$app/stores';
import { dev } from '$app/environment';
</script>
<h1>Oops! Something went wrong</h1>
{#if dev}
<!-- Show details in dev mode -->
<pre>{JSON.stringify($page.error, null, 2)}</pre>
{:else}
<!-- Generic message in production -->
<p>We're sorry, but something unexpected happened.</p>
{/if}
<a href="/">Go home</a>
```
## Testing Error Boundaries
```typescript
// +page.server.ts
export const load = async ({ url }) => {
// Test error page in dev
if (url.searchParams.has('test-error')) {
throw error(500, 'Test error');
}
return {};
};
```
Visit `http://localhost:5173/page?test-error` to see error boundary.
## Key Takeaways
1. ✅ Place `+error.svelte` ABOVE failing routes
2. ✅ Errors bubble up to nearest error boundary
3. ✅ Always have a root `src/routes/+error.svelte`
4. ✅ Throw errors in `load` functions, not components
5. ✅ Use specific status codes (401, 403, 404, 500)
6. ✅ Customize error pages per section (blog, admin, etc.)
7. ✅ Use `handleError` hook for logging/monitoring
8. ✅ Show detailed errors in dev, generic in production
@@ -0,0 +1,69 @@
# File Naming Conventions
## Core Files
| File | Purpose | Runs | Example |
| ------------------- | --------------------- | --------------------- | --------------------------------------------- |
| `+page.svelte` | Page component | Client & Server (SSR) | `/routes/about/+page.svelte` → `/about` |
| `+page.ts` | Universal load | Client & Server | Data for +page.svelte |
| `+page.server.ts` | Server load & actions | Server only | DB queries, form actions |
| `+layout.svelte` | Layout wrapper | Client & Server | Wraps child routes |
| `+layout.ts` | Layout universal load | Client & Server | Data for +layout.svelte |
| `+layout.server.ts` | Layout server load | Server only | Auth, user data |
| `+error.svelte` | Error boundary | Client & Server | Shown when error thrown |
| `+server.ts` | API endpoint | Server only | `/routes/api/users/+server.ts` → `/api/users` |
## Route Parameters
| Pattern | Matches | Example |
| -------------- | -------------- | ---------------------------------------------------------------- |
| `[id]` | Single param | `/posts/[id]/+page.svelte` → `/posts/123` |
| `[slug]` | Single param | `/blog/[slug]/+page.svelte` → `/blog/hello-world` |
| `[[optional]]` | Optional param | `/search/[[query]]/+page.svelte` → `/search` or `/search/svelte` |
| `[...rest]` | Rest params | `/docs/[...path]/+page.svelte` → `/docs/a/b/c` |
## Route Groups
| Pattern | Purpose | URL |
| ------------- | ---------------------------- | ---------------------------------------------- |
| `(group)` | Group routes (no URL impact) | `/(app)/dashboard/+page.svelte` → `/dashboard` |
| `(marketing)` | Separate layouts | Different layout for marketing pages |
## Special Files
- `hooks.server.ts` - Server hooks (handle function, runs on every
request)
- `hooks.client.ts` - Client hooks (runs in browser)
- `app.html` - HTML template
- `service-worker.ts` - Service worker
- `params/*.ts` - Param validators
## Common Patterns
```
src/routes/
├── +layout.svelte # Root layout
├── +layout.ts # Root data
├── +page.svelte # Homepage
├── +error.svelte # Root error boundary
│
├── (app)/ # App routes (grouped)
│ ├── +layout.svelte # App layout (auth required)
│ ├── dashboard/+page.svelte # /dashboard
│ └── settings/+page.svelte # /settings
│
├── (marketing)/ # Marketing routes (grouped)
│ ├── +layout.svelte # Marketing layout
│ ├── about/+page.svelte # /about
│ └── pricing/+page.svelte # /pricing
│
├── blog/
│ ├── +page.svelte # /blog (list)
│ └── [slug]/
│ ├── +page.svelte # /blog/post-title
│ └── +page.server.ts # Load post data
│
└── api/
└── posts/
└── +server.ts # API: GET/POST /api/posts
```
@@ -0,0 +1,387 @@
# Layout Patterns
## Basic Layout
```svelte
<!-- src/routes/+layout.svelte -->
<script>
let { children } = $props();
</script>
<header>Header</header>
<main>{@render children()}</main>
<footer>Footer</footer>
```
**Key points:**
- Must declare `children` in `$props()`
- Use `{@render children()}` to render nested content
- Root layout wraps ALL pages
## Nested Layouts
Layouts inherit from parent layouts:
```
src/routes/
├── +layout.svelte # Root layout (all pages)
└── dashboard/
├── +layout.svelte # Dashboard layout (dashboard pages only)
└── +page.svelte # Uses both layouts
```
**Result:** Root layout wraps dashboard layout wraps page.
**Rendering order:**
```
Root +layout.svelte
└─ Dashboard +layout.svelte
└─ +page.svelte
```
### Example: Nested Layout Pattern
```svelte
<!-- src/routes/+layout.svelte -->
<script>
let { children } = $props();
</script>
<div class="app">
<nav>Global Nav</nav>
{@render children()}
</div>
```
```svelte
<!-- src/routes/dashboard/+layout.svelte -->
<script>
let { children } = $props();
</script>
<div class="dashboard">
<aside>Dashboard Sidebar</aside>
<main>{@render children()}</main>
</div>
```
```svelte
<!-- src/routes/dashboard/+page.svelte --><h1>Dashboard Home</h1>
```
**Rendered HTML structure:**
```html
<div class="app">
<nav>Global Nav</nav>
<div class="dashboard">
<aside>Dashboard Sidebar</aside>
<main>
<h1>Dashboard Home</h1>
</main>
</div>
</div>
```
## Layout Groups
Use `(groups)` to organize without affecting URLs:
```
src/routes/
├── (marketing)/
│ ├── +layout.svelte # Marketing layout
│ ├── about/+page.svelte # /about (uses marketing layout)
│ └── pricing/+page.svelte # /pricing (uses marketing layout)
│
└── (app)/
├── +layout.svelte # App layout
├── dashboard/+page.svelte # /dashboard (uses app layout)
└── settings/+page.svelte # /settings (uses app layout)
```
**Key points:**
- Parentheses make groups invisible in URLs
- `/about` route, NOT `/(marketing)/about`
- Different layouts for different sections
- Useful for authentication boundaries
### Example: Marketing vs App Layouts
```svelte
<!-- src/routes/(marketing)/+layout.svelte -->
<script>
let { children } = $props();
</script>
<header class="marketing-header">
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/login">Login</a>
</nav>
</header>
{@render children()}
<footer>© 2024 Company</footer>
```
```svelte
<!-- src/routes/(app)/+layout.svelte -->
<script>
let { children, data } = $props();
</script>
<header class="app-header">
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/settings">Settings</a>
<span>Welcome, {data.user.name}</span>
</nav>
</header>
{@render children()}
```
## Reset Layout
Use `@` in filename to break layout inheritance:
**NOT RECOMMENDED** - This feature is deprecated in SvelteKit 2+.
Instead, use layout groups to create separate hierarchies:
```
src/routes/
├── +layout.svelte # Root layout (for most pages)
├── (app)/
│ └── +layout.svelte # App layout
└── (public)/
└── +layout.svelte # Public layout (completely separate)
```
## Layout with Data Loading
```svelte
<!-- src/routes/+layout.server.ts -->
export const load = async ({ locals }) => {
// Available to all child routes
return {
user: locals.user,
};
};
```
```svelte
<!-- src/routes/+layout.svelte -->
<script>
let { children, data } = $props();
</script>
<header>
{#if data.user}
<span>Welcome, {data.user.name}</span>
{:else}
<a href="/login">Login</a>
{/if}
</header>
{@render children()}
```
## Conditional Layout Content
```svelte
<!-- +layout.svelte -->
<script>
import { page } from '$app/stores';
let { children } = $props();
</script>
{#if !$page.url.pathname.startsWith('/admin')}
<header>Public Header</header>
{/if}
{@render children()}
{#if !$page.url.pathname.includes('/checkout')}
<footer>Footer</footer>
{/if}
```
**Better approach:** Use layout groups instead of conditionals.
## Protected Layouts
```typescript
// src/routes/(app)/+layout.server.ts
import { redirect } from '@sveltejs/kit';
export const load = async ({ locals }) => {
if (!locals.user) {
throw redirect(303, '/login');
}
return {
user: locals.user,
};
};
```
All routes under `(app)` group now require authentication.
## Sharing Layout State
```svelte
<!-- src/routes/+layout.svelte -->
<script>
import { setContext } from 'svelte';
let { children, data } = $props();
// Share state with all descendant components
setContext('user', data.user);
</script>
{@render children()}
```
```svelte
<!-- Any child component -->
<script>
import { getContext } from 'svelte';
const user = getContext('user');
</script>
<p>Hello, {user.name}</p>
```
## Layout Slot Props (Snippets)
```svelte
<!-- src/routes/dashboard/+layout.svelte -->
<script>
let { children, header } = $props();
</script>
<div class="dashboard">
<aside>Sidebar</aside>
<div class="content">
{#if header}
<header>{@render header()}</header>
{/if}
<main>{@render children()}</main>
</div>
</div>
```
```svelte
<!-- src/routes/dashboard/+page.svelte -->
{#snippet header()}
<h1>Custom Dashboard Header</h1>
{/snippet}
<p>Dashboard content</p>
```
## Common Patterns
### Pattern 1: Auth Boundary
```
src/routes/
├── +layout.svelte # Root (no auth)
├── (public)/
│ ├── +layout.svelte # Public layout (landing pages)
│ ├── +page.svelte # /
│ └── about/+page.svelte # /about
└── (protected)/
├── +layout.server.ts # Check auth, redirect if not logged in
├── +layout.svelte # Protected layout (app UI)
├── dashboard/+page.svelte # /dashboard
└── settings/+page.svelte # /settings
```
### Pattern 2: Multi-tenant with Shared Root
```
src/routes/
├── +layout.svelte # Root layout (shared)
├── admin/
│ ├── +layout.svelte # Admin layout
│ └── users/+page.svelte # /admin/users
└── client/
├── +layout.svelte # Client layout
└── projects/+page.svelte # /client/projects
```
### Pattern 3: Progressive Enhancement
```svelte
<!-- src/routes/+layout.svelte -->
<script>
import { page } from '$app/stores';
import { navigating } from '$app/stores';
let { children } = $props();
</script>
<div class="app">
{#if $navigating}
<div class="loading-bar"></div>
{/if}
{@render children()}
</div>
<style>
.loading-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: blue;
animation: loading 1s ease-in-out;
}
</style>
```
## Layout Best Practices
1. ✅ Keep root layout minimal (shared across ALL pages)
2. ✅ Use layout groups for separate sections
3. ✅ Load shared data in layout's load function
4. ✅ Use context for sharing state with descendants
5. ✅ Avoid too many nested layouts (max 2-3 levels)
6. ✅ Don't put auth logic in root layout (use groups)
7. ✅ Remember: layouts share data DOWN, not UP
8. ❌ Avoid conditionals in layouts (use groups instead)
## Debugging Layouts
```svelte
<!-- src/routes/+layout.svelte -->
<script>
import { page } from '$app/stores';
let { children } = $props();
$effect(() => {
console.log('Current route:', $page.url.pathname);
console.log('Layout data:', $page.data);
});
</script>
{@render children()}
```
## Key Takeaways
- Layouts wrap all child routes
- Nested layouts create hierarchy (root → section → page)
- Groups `(name)` organize without affecting URLs
- Load data in `+layout.server.ts` to share with children
- Use context to share reactive state
- Keep layouts simple and focused
@@ -0,0 +1,92 @@
# SSR & Hydration
## The Problem
SvelteKit runs on server (SSR) then hydrates in browser. Code using
browser APIs (`window`, `document`, `localStorage`) fails on server.
## Solution: Check for Browser
```typescript
import { browser } from '$app/environment';
// In load function
export const load = async () => {
const theme = browser ? localStorage.getItem('theme') : 'light';
return { theme };
};
```
```svelte
<!-- In component -->
<script>
import { browser } from '$app/environment';
import { onMount } from 'svelte';
let data = $state(null);
// Option 1: Use browser check
if (browser) {
data = localStorage.getItem('data');
}
// Option 2: Use onMount (only runs in browser)
onMount(() => {
data = localStorage.getItem('data');
});
// Option 3: Use $effect with browser check
$effect(() => {
if (browser) {
data = localStorage.getItem('data');
}
});
</script>
```
## Common Mistakes
### ❌ Using window Without Check
```typescript
// WRONG - fails on server
export const load = async () => {
const width = window.innerWidth; // ERROR on server
return { width };
};
// RIGHT
import { browser } from '$app/environment';
export const load = async () => {
const width = browser ? window.innerWidth : 1024;
return { width };
};
```
### ❌ Accessing DOM in Load
```typescript
// WRONG
export const load = async () => {
const el = document.getElementById('root'); // ERROR on server
};
// RIGHT - Do DOM stuff in onMount
export const load = async () => {
return {};
};
// Then in component:
onMount(() => {
const el = document.getElementById('root');
});
```
## Disable SSR (Not Recommended)
```typescript
// +page.ts
export const ssr = false; // Disables SSR for this page
```
Only use when absolutely necessary (e.g., heavy Canvas/WebGL).
@@ -0,0 +1,150 @@
# svelte:boundary Component
> Available in Svelte 5.3+
## Two Purposes
1. **Error boundaries** - catch rendering errors
2. **Pending UI** - show loading state while `await` resolves
## Basic Error Boundary
```svelte
<svelte:boundary onerror={(e, reset) => console.error(e)}>
<RiskyComponent />
{#snippet failed(error, reset)}
<p>Error: {error.message}</p>
<button onclick={reset}>Try again</button>
{/snippet}
</svelte:boundary>
```
## Pending UI (Loading States)
> **⚠️ Known Bug:** `<svelte:boundary>` + `{@const await}` causes
> infinite navigation loops during client-side page transitions when
> pages share async queries. See
> [sveltejs/svelte#17717](https://github.com/sveltejs/svelte/issues/17717).
> Use `{#await}` blocks until this is fixed.
With `experimental.async: true` (Svelte 5.36+), `{@const await}` is
possible but **not safe for pages with shared queries or navigation**:
```svelte
<!-- ⚠️ Causes navigation loops - use {#await} instead -->
<svelte:boundary>
{#snippet pending()}
<LoadingSpinner />
{/snippet}
{@const data = await loadData()}
<DataView {data} />
</svelte:boundary>
```
**Safe alternative:**
```svelte
{#await loadData()}
<LoadingSpinner />
{:then data}
<DataView {data} />
{:catch error}
<p>Error: {error.message}</p>
{/await}
```
## Combined Error + Pending
Use `svelte:boundary` for **error catching only**, with `{#await}` for
async data:
```svelte
<svelte:boundary onerror={logError}>
{#snippet failed(error, reset)}
<p>Failed to load user</p>
<button onclick={reset}>Retry</button>
{/snippet}
{#await fetchUser()}
<p>Loading user...</p>
{:then user}
<UserProfile {user} />
{:catch error}
<p>Error: {error.message}</p>
{/await}
</svelte:boundary>
```
## What Gets Caught
**Caught:**
- Errors during rendering
- Errors in `$effect`
**NOT Caught:**
- Event handler errors (`onclick`, etc.)
- Errors after `setTimeout`
- Async errors outside boundary's await
## vs +error.svelte
| Feature | svelte:boundary | +error.svelte |
| -------- | ----------------------- | ------------- |
| Scope | Component subtree | Route segment |
| Reset | Built-in reset function | Navigate away |
| Pending | Yes (pending snippet) | No |
| Use case | Component-level | Page-level |
## Error Tracking Integration
```svelte
<svelte:boundary
onerror={(error, reset) => {
// Send to Sentry, LogRocket, etc.
errorTracker.captureException(error);
}}
>
<App />
{#snippet failed(error, reset)}
<ErrorFallback {error} {reset} />
{/snippet}
</svelte:boundary>
```
## Nested Boundaries
Inner boundary catches first:
```svelte
<svelte:boundary>
<!-- Outer fallback -->
{#snippet failed(e)}
<p>Outer caught: {e.message}</p>
{/snippet}
<svelte:boundary>
<!-- Inner fallback -->
{#snippet failed(e)}
<p>Inner caught: {e.message}</p>
{/snippet}
<ComponentThatMightFail />
</svelte:boundary>
</svelte:boundary>
```
## Key Points
- Use `svelte:boundary` for component-level error isolation
- Use `+error.svelte` for route-level error pages
- `pending` snippet shows only on **initial** load (not on refresh - no flicker)
- `failed` snippet replaces content on error
- `reset` function lets users retry
- Errors in event handlers are NOT caught
- Requires `experimental.async: true` in svelte.config.js for `{@const await}`
- **⚠️ Bug:** `{@const await}` + navigation causes infinite loops ([#17717](https://github.com/sveltejs/svelte/issues/17717)) — use `{#await}` instead
@@ -0,0 +1,270 @@
---
name: typescript-best-practices
description: Provides TypeScript patterns for type-first development, making illegal states unrepresentable, exhaustive handling, and runtime validation. Must use when reading or writing TypeScript/JavaScript files.
---
# TypeScript Best Practices
## Pair with React Best Practices
When working with React components (`.tsx`, `.jsx` files or `@react` imports), always load `react-best-practices` alongside this skill. This skill covers TypeScript fundamentals; React-specific patterns (effects, hooks, refs, component design) are in the dedicated React skill.
## Type-First Development
Types define the contract before implementation. Follow this workflow:
1. **Define the data model** - types, interfaces, and schemas first
2. **Define function signatures** - input/output types before logic
3. **Implement to satisfy types** - let the compiler guide completeness
4. **Validate at boundaries** - runtime checks where data enters the system
### Make Illegal States Unrepresentable
Use the type system to prevent invalid states at compile time.
**Discriminated unions for mutually exclusive states:**
```ts
// Good: only valid combinations possible
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
// Bad: allows invalid combinations like { loading: true, error: Error }
type RequestState<T> = {
loading: boolean;
data?: T;
error?: Error;
};
```
**Branded types for domain primitives:**
```ts
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
// Compiler prevents passing OrderId where UserId expected
function getUser(id: UserId): Promise<User> { /* ... */ }
function createUserId(id: string): UserId {
return id as UserId;
}
```
**Const assertions for literal unions:**
```ts
const ROLES = ['admin', 'user', 'guest'] as const;
type Role = typeof ROLES[number]; // 'admin' | 'user' | 'guest'
// Array and type stay in sync automatically
function isValidRole(role: string): role is Role {
return ROLES.includes(role as Role);
}
```
**Required vs optional fields - be explicit:**
```ts
// Creation: some fields required
type CreateUser = {
email: string;
name: string;
};
// Update: all fields optional
type UpdateUser = Partial<CreateUser>;
// Database row: all fields present
type User = CreateUser & {
id: UserId;
createdAt: Date;
};
```
## Module Structure
Prefer smaller, focused files: one component, hook, or utility per file. Split when a file handles multiple concerns or exceeds ~200 lines. Colocate tests with implementation (`foo.test.ts` alongside `foo.ts`). Group related files by feature rather than by type.
## Functional Patterns
- Prefer `const` over `let`; use `readonly` and `Readonly<T>` for immutable data.
- Use `array.map/filter/reduce` over `for` loops; chain transformations in pipelines.
- Write pure functions for business logic; isolate side effects in dedicated modules.
- Avoid mutating function parameters; return new objects/arrays instead.
## Instructions
- Enable `strict` mode; model data with interfaces and types. Strong typing catches bugs at compile time.
- Every code path returns a value or throws; use exhaustive `switch` with `never` checks in default. Unhandled cases become compile errors.
- Propagate errors with context; catching requires re-throwing or returning a meaningful result. Hidden failures delay debugging.
- Handle edge cases explicitly: empty arrays, null/undefined inputs, boundary values. Defensive checks prevent runtime surprises.
- Use `await` for async calls; wrap external calls with contextual error messages. Unhandled rejections crash Node processes.
- Add or update focused tests when changing logic; test behavior, not implementation details.
## Examples
Explicit failure for unimplemented logic:
```ts
export function buildWidget(widgetType: string): never {
throw new Error(`buildWidget not implemented for type: ${widgetType}`);
}
```
Exhaustive switch with never check:
```ts
type Status = "active" | "inactive";
export function processStatus(status: Status): string {
switch (status) {
case "active":
return "processing";
case "inactive":
return "skipped";
default: {
const _exhaustive: never = status;
throw new Error(`unhandled status: ${_exhaustive}`);
}
}
}
```
Wrap external calls with context:
```ts
export async function fetchWidget(id: string): Promise<Widget> {
const response = await fetch(`/api/widgets/${id}`);
if (!response.ok) {
throw new Error(`fetch widget ${id} failed: ${response.status}`);
}
return response.json();
}
```
Debug logging with namespaced logger:
```ts
import debug from "debug";
const log = debug("myapp:widgets");
export function createWidget(name: string): Widget {
log("creating widget: %s", name);
const widget = { id: crypto.randomUUID(), name };
log("created widget: %s", widget.id);
return widget;
}
```
## Runtime Validation with Zod
- Define schemas as single source of truth; infer TypeScript types with `z.infer<>`. Avoid duplicating types and schemas.
- Use `safeParse` for user input where failure is expected; use `parse` at trust boundaries where invalid data is a bug.
- Compose schemas with `.extend()`, `.pick()`, `.omit()`, `.merge()` for DRY definitions.
- Add `.transform()` for data normalization at parse time (trim strings, parse dates).
- Include descriptive error messages; use `.refine()` for custom validation logic.
### Examples
Schema as source of truth with type inference:
```ts
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1),
createdAt: z.string().transform((s) => new Date(s)),
});
type User = z.infer<typeof UserSchema>;
```
Return parse results to callers (never swallow errors):
```ts
import { z, SafeParseReturnType } from "zod";
export function parseUserInput(raw: unknown): SafeParseReturnType<unknown, User> {
return UserSchema.safeParse(raw);
}
// Caller handles both success and error:
const result = parseUserInput(formData);
if (!result.success) {
setErrors(result.error.flatten().fieldErrors);
return;
}
await submitUser(result.data);
```
Strict parsing at trust boundaries:
```ts
export async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`fetch user ${id} failed: ${response.status}`);
}
const data = await response.json();
return UserSchema.parse(data); // throws if API contract violated
}
```
Schema composition:
```ts
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
const UpdateUserSchema = CreateUserSchema.partial();
const UserWithPostsSchema = UserSchema.extend({
posts: z.array(PostSchema),
});
```
## Configuration
- Load config from environment variables at startup; validate with Zod before use. Invalid config should crash immediately.
- Define a typed config object as single source of truth; avoid accessing `process.env` throughout the codebase.
- Use sensible defaults for development; require explicit values for production secrets.
### Examples
Typed config with Zod validation:
```ts
import { z } from "zod";
const ConfigSchema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(1),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});
export const config = ConfigSchema.parse(process.env);
```
Access config values (not process.env directly):
```ts
import { config } from "./config";
const server = app.listen(config.PORT);
const db = connect(config.DATABASE_URL);
```
## Optional: type-fest
For advanced type utilities beyond TypeScript builtins, consider [type-fest](https://github.com/sindresorhus/type-fest):
- `Opaque<T, Token>` - cleaner branded types than manual `& { __brand }` pattern
- `PartialDeep<T>` - recursive partial for nested objects
- `ReadonlyDeep<T>` - recursive readonly for immutable data
- `LiteralUnion<Literals, Fallback>` - literals with autocomplete + string fallback
- `SetRequired<T, K>` / `SetOptional<T, K>` - targeted field modifications
- `Simplify<T>` - flatten complex intersection types in IDE tooltips
```ts
import type { Opaque, PartialDeep, SetRequired } from 'type-fest';
// Branded type (cleaner than manual approach)
type UserId = Opaque<string, 'UserId'>;
// Deep partial for patch operations
type UserPatch = PartialDeep<User>;
// Make specific fields required
type UserWithEmail = SetRequired<Partial<User>, 'email'>;
```
+3
View File
@@ -31,3 +31,6 @@ vite.config.ts.timestamp-*
# Test results # Test results
/test-results /test-results
/playwright-report /playwright-report
# Skills CLI (actual files are in .agents/skills/)
/.claude/skills
+30
View File
@@ -0,0 +1,30 @@
{
"version": 1,
"skills": {
"drizzle-migrations": {
"source": "erichowens/some_claude_skills",
"sourceType": "github",
"computedHash": "d2af96b92935e26f53d97616aa51a02f114c9da8129c5943408d3b4b19a10423"
},
"svelte5-best-practices": {
"source": "ejirocodes/agent-skills",
"sourceType": "github",
"computedHash": "bfcbba3d355aede6f6be9720f195206d4dcaa7c152787ea00c06cb4a1ac44ba4"
},
"sveltekit-data-flow": {
"source": "spences10/svelte-skills-kit",
"sourceType": "github",
"computedHash": "1ef78409b38d1cb7324d8e0a78ac3f4303e0a9c5b5789bfc2d646c0806faa618"
},
"sveltekit-structure": {
"source": "spences10/svelte-skills-kit",
"sourceType": "github",
"computedHash": "a935fd51b32e4e37d23286c04748825a6a193ef74d9980385449aa4f80646f9d"
},
"typescript-best-practices": {
"source": "0xbigboss/claude-code",
"sourceType": "github",
"computedHash": "838d6737e59e8b15457a6af23112bc3a107a8ac098606a19a43c2be0db2dbe5a"
}
}
}