0b76a4b119
- Delete Mode1Test, Mode2Test, Mode3Test, Mode5Test (keep only Mode6Test) - Delete old documentation, Docker files, and k8s configs - Add comprehensive Svelte 5 reference library (svelte-reference/) - Update ModeSwitch orchestrator: - Fix port to 3005 (never use 3000/3001) - Add API endpoints for mode switching - Add bun-types and proper tsconfig - Clean up mode folder mappings
729 lines
16 KiB
Markdown
729 lines
16 KiB
Markdown
# SvelteKit Routing & Data Loading Guide
|
|
|
|
## Overview
|
|
|
|
SvelteKit is the official framework for building Svelte applications. It provides file-based routing, server-side rendering, and powerful data loading primitives.
|
|
|
|
---
|
|
|
|
## 📁 File-Based Routing
|
|
|
|
### Route Structure
|
|
```
|
|
src/routes/
|
|
├── +page.svelte # → /
|
|
├── +layout.svelte # Layout for all routes
|
|
├── about/
|
|
│ └── +page.svelte # → /about
|
|
├── blog/
|
|
│ ├── +page.svelte # → /blog
|
|
│ └── [slug]/
|
|
│ └── +page.svelte # → /blog/:slug
|
|
├── users/
|
|
│ ├── +page.svelte # → /users
|
|
│ ├── [id]/
|
|
│ │ ├── +page.svelte # → /users/:id
|
|
│ │ └── +page.server.ts # Server-only load
|
|
│ └── [...rest]/
|
|
│ └── +page.svelte # → /users/* (catch-all)
|
|
└── api/
|
|
└── posts/
|
|
└── +server.ts # → /api/posts (API endpoint)
|
|
```
|
|
|
|
### Route Files Explained
|
|
|
|
| File | Purpose | Runs On |
|
|
|------|---------|---------|
|
|
| `+page.svelte` | Page component | Client |
|
|
| `+page.ts` | Load data for page | Client & Server |
|
|
| `+page.server.ts` | Load data (server only) | Server only |
|
|
| `+layout.svelte` | Shared layout | Client |
|
|
| `+layout.ts` | Load data for layout | Client & Server |
|
|
| `+layout.server.ts` | Load layout data (server) | Server only |
|
|
| `+server.ts` | API endpoint | Server only |
|
|
| `+error.svelte` | Error page | Client |
|
|
|
|
---
|
|
|
|
## 📥 Data Loading
|
|
|
|
### Basic Load Function (+page.ts)
|
|
|
|
```typescript
|
|
// src/routes/posts/+page.ts
|
|
import type { PageLoad } from './$types';
|
|
|
|
export const load: PageLoad = async ({ fetch }) => {
|
|
const response = await fetch('/api/posts');
|
|
const posts = await response.json();
|
|
|
|
return {
|
|
posts
|
|
};
|
|
};
|
|
```
|
|
|
|
```svelte
|
|
<!-- src/routes/posts/+page.svelte -->
|
|
<script lang="ts">
|
|
import type { PageData } from './$types';
|
|
|
|
let { data }: { data: PageData } = $props();
|
|
</script>
|
|
|
|
<h1>Posts</h1>
|
|
|
|
{#each data.posts as post}
|
|
<article>
|
|
<h2>{post.title}</h2>
|
|
<p>{post.excerpt}</p>
|
|
</article>
|
|
{/each}
|
|
```
|
|
|
|
### Server-Only Load (+page.server.ts)
|
|
|
|
```typescript
|
|
// src/routes/dashboard/+page.server.ts
|
|
import type { PageServerLoad } from './$types';
|
|
import { db } from '$lib/server/database';
|
|
import { error, redirect } from '@sveltejs/kit';
|
|
|
|
export const load: PageServerLoad = async ({ locals, cookies }) => {
|
|
// Access server-only resources
|
|
const session = cookies.get('session');
|
|
|
|
if (!session) {
|
|
redirect(303, '/login');
|
|
}
|
|
|
|
const user = await db.user.findUnique({
|
|
where: { sessionId: session }
|
|
});
|
|
|
|
if (!user) {
|
|
error(401, 'Unauthorized');
|
|
}
|
|
|
|
const stats = await db.stats.findMany({
|
|
where: { userId: user.id }
|
|
});
|
|
|
|
return {
|
|
user: {
|
|
name: user.name,
|
|
email: user.email
|
|
},
|
|
stats
|
|
};
|
|
};
|
|
```
|
|
|
|
### Load with Parameters
|
|
|
|
```typescript
|
|
// src/routes/blog/[slug]/+page.ts
|
|
import type { PageLoad } from './$types';
|
|
import { error } from '@sveltejs/kit';
|
|
|
|
export const load: PageLoad = async ({ params, fetch }) => {
|
|
const response = await fetch(`/api/posts/${params.slug}`);
|
|
|
|
if (!response.ok) {
|
|
error(404, 'Post not found');
|
|
}
|
|
|
|
const post = await response.json();
|
|
|
|
return {
|
|
post
|
|
};
|
|
};
|
|
```
|
|
|
|
### Parent Data Access
|
|
|
|
```typescript
|
|
// src/routes/+layout.ts
|
|
import type { LayoutLoad } from './$types';
|
|
|
|
export const load: LayoutLoad = async ({ fetch }) => {
|
|
const response = await fetch('/api/user');
|
|
const user = response.ok ? await response.json() : null;
|
|
|
|
return { user };
|
|
};
|
|
|
|
// src/routes/dashboard/+page.ts
|
|
import type { PageLoad } from './$types';
|
|
|
|
export const load: PageLoad = async ({ parent }) => {
|
|
// Access data from parent layout
|
|
const { user } = await parent();
|
|
|
|
if (!user) {
|
|
// Handle unauthenticated state
|
|
}
|
|
|
|
return {
|
|
// Additional data
|
|
};
|
|
};
|
|
```
|
|
|
|
### Parallel Data Loading
|
|
|
|
```typescript
|
|
// src/routes/dashboard/+page.ts
|
|
import type { PageLoad } from './$types';
|
|
|
|
export const load: PageLoad = async ({ fetch }) => {
|
|
// Load data in parallel
|
|
const [postsRes, statsRes, notificationsRes] = await Promise.all([
|
|
fetch('/api/posts'),
|
|
fetch('/api/stats'),
|
|
fetch('/api/notifications')
|
|
]);
|
|
|
|
return {
|
|
posts: postsRes.json(),
|
|
stats: statsRes.json(),
|
|
notifications: notificationsRes.json()
|
|
};
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 📤 Form Actions
|
|
|
|
### Basic Form Action
|
|
|
|
```typescript
|
|
// src/routes/login/+page.server.ts
|
|
import type { Actions, PageServerLoad } from './$types';
|
|
import { fail, redirect } from '@sveltejs/kit';
|
|
import { db } from '$lib/server/database';
|
|
|
|
export const load: PageServerLoad = async ({ locals }) => {
|
|
if (locals.user) {
|
|
redirect(303, '/dashboard');
|
|
}
|
|
};
|
|
|
|
export const actions: Actions = {
|
|
default: async ({ request, cookies }) => {
|
|
const data = await request.formData();
|
|
const email = data.get('email')?.toString();
|
|
const password = data.get('password')?.toString();
|
|
|
|
if (!email || !password) {
|
|
return fail(400, {
|
|
email,
|
|
error: 'Email and password are required'
|
|
});
|
|
}
|
|
|
|
const user = await db.user.findUnique({ where: { email } });
|
|
|
|
if (!user || !await verifyPassword(password, user.passwordHash)) {
|
|
return fail(401, {
|
|
email,
|
|
error: 'Invalid credentials'
|
|
});
|
|
}
|
|
|
|
const session = await createSession(user.id);
|
|
cookies.set('session', session.id, {
|
|
path: '/',
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'lax',
|
|
maxAge: 60 * 60 * 24 * 7 // 1 week
|
|
});
|
|
|
|
redirect(303, '/dashboard');
|
|
}
|
|
};
|
|
```
|
|
|
|
```svelte
|
|
<!-- src/routes/login/+page.svelte -->
|
|
<script lang="ts">
|
|
import type { ActionData, PageData } from './$types';
|
|
import { enhance } from '$app/forms';
|
|
|
|
let { form }: { form: ActionData } = $props();
|
|
|
|
let loading = $state(false);
|
|
</script>
|
|
|
|
<form
|
|
method="POST"
|
|
use:enhance={() => {
|
|
loading = true;
|
|
return async ({ update }) => {
|
|
loading = false;
|
|
await update();
|
|
};
|
|
}}
|
|
>
|
|
{#if form?.error}
|
|
<p class="error">{form.error}</p>
|
|
{/if}
|
|
|
|
<label>
|
|
Email
|
|
<input
|
|
type="email"
|
|
name="email"
|
|
value={form?.email ?? ''}
|
|
required
|
|
/>
|
|
</label>
|
|
|
|
<label>
|
|
Password
|
|
<input type="password" name="password" required />
|
|
</label>
|
|
|
|
<button type="submit" disabled={loading}>
|
|
{loading ? 'Logging in...' : 'Login'}
|
|
</button>
|
|
</form>
|
|
```
|
|
|
|
### Named Actions
|
|
|
|
```typescript
|
|
// src/routes/settings/+page.server.ts
|
|
import type { Actions } from './$types';
|
|
|
|
export const actions: Actions = {
|
|
updateProfile: async ({ request }) => {
|
|
const data = await request.formData();
|
|
// Handle profile update
|
|
return { success: true };
|
|
},
|
|
|
|
changePassword: async ({ request }) => {
|
|
const data = await request.formData();
|
|
// Handle password change
|
|
return { success: true };
|
|
},
|
|
|
|
deleteAccount: async ({ request, cookies }) => {
|
|
// Handle account deletion
|
|
cookies.delete('session', { path: '/' });
|
|
redirect(303, '/');
|
|
}
|
|
};
|
|
```
|
|
|
|
```svelte
|
|
<!-- Use named actions -->
|
|
<form method="POST" action="?/updateProfile" use:enhance>
|
|
<!-- Profile form -->
|
|
</form>
|
|
|
|
<form method="POST" action="?/changePassword" use:enhance>
|
|
<!-- Password form -->
|
|
</form>
|
|
|
|
<form method="POST" action="?/deleteAccount" use:enhance>
|
|
<button type="submit">Delete Account</button>
|
|
</form>
|
|
```
|
|
|
|
---
|
|
|
|
## 🔌 API Routes (+server.ts)
|
|
|
|
### RESTful API Endpoint
|
|
|
|
```typescript
|
|
// src/routes/api/posts/+server.ts
|
|
import type { RequestHandler } from './$types';
|
|
import { json, error } from '@sveltejs/kit';
|
|
import { db } from '$lib/server/database';
|
|
|
|
// GET /api/posts
|
|
export const GET: RequestHandler = async ({ url }) => {
|
|
const limit = Number(url.searchParams.get('limit')) || 10;
|
|
const offset = Number(url.searchParams.get('offset')) || 0;
|
|
|
|
const posts = await db.post.findMany({
|
|
take: limit,
|
|
skip: offset,
|
|
orderBy: { createdAt: 'desc' }
|
|
});
|
|
|
|
return json(posts);
|
|
};
|
|
|
|
// POST /api/posts
|
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
|
if (!locals.user) {
|
|
error(401, 'Unauthorized');
|
|
}
|
|
|
|
const body = await request.json();
|
|
|
|
const post = await db.post.create({
|
|
data: {
|
|
title: body.title,
|
|
content: body.content,
|
|
authorId: locals.user.id
|
|
}
|
|
});
|
|
|
|
return json(post, { status: 201 });
|
|
};
|
|
|
|
// src/routes/api/posts/[id]/+server.ts
|
|
import type { RequestHandler } from './$types';
|
|
import { json, error } from '@sveltejs/kit';
|
|
|
|
// GET /api/posts/:id
|
|
export const GET: RequestHandler = async ({ params }) => {
|
|
const post = await db.post.findUnique({
|
|
where: { id: params.id }
|
|
});
|
|
|
|
if (!post) {
|
|
error(404, 'Post not found');
|
|
}
|
|
|
|
return json(post);
|
|
};
|
|
|
|
// PUT /api/posts/:id
|
|
export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
|
const post = await db.post.findUnique({
|
|
where: { id: params.id }
|
|
});
|
|
|
|
if (!post) {
|
|
error(404, 'Post not found');
|
|
}
|
|
|
|
if (post.authorId !== locals.user?.id) {
|
|
error(403, 'Forbidden');
|
|
}
|
|
|
|
const body = await request.json();
|
|
|
|
const updated = await db.post.update({
|
|
where: { id: params.id },
|
|
data: body
|
|
});
|
|
|
|
return json(updated);
|
|
};
|
|
|
|
// DELETE /api/posts/:id
|
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
|
const post = await db.post.findUnique({
|
|
where: { id: params.id }
|
|
});
|
|
|
|
if (!post) {
|
|
error(404, 'Post not found');
|
|
}
|
|
|
|
if (post.authorId !== locals.user?.id) {
|
|
error(403, 'Forbidden');
|
|
}
|
|
|
|
await db.post.delete({
|
|
where: { id: params.id }
|
|
});
|
|
|
|
return new Response(null, { status: 204 });
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 🏗️ Layouts
|
|
|
|
### Root Layout
|
|
|
|
```svelte
|
|
<!-- src/routes/+layout.svelte -->
|
|
<script lang="ts">
|
|
import type { LayoutData } from './$types';
|
|
import type { Snippet } from 'svelte';
|
|
import Header from '$lib/components/Header.svelte';
|
|
import Footer from '$lib/components/Footer.svelte';
|
|
import '../app.css';
|
|
|
|
let { data, children }: { data: LayoutData; children: Snippet } = $props();
|
|
</script>
|
|
|
|
<div class="app">
|
|
<Header user={data.user} />
|
|
|
|
<main>
|
|
{@render children()}
|
|
</main>
|
|
|
|
<Footer />
|
|
</div>
|
|
```
|
|
|
|
### Nested Layouts
|
|
|
|
```svelte
|
|
<!-- src/routes/dashboard/+layout.svelte -->
|
|
<script lang="ts">
|
|
import type { LayoutData } from './$types';
|
|
import type { Snippet } from 'svelte';
|
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
|
|
|
let { data, children }: { data: LayoutData; children: Snippet } = $props();
|
|
</script>
|
|
|
|
<div class="dashboard">
|
|
<Sidebar />
|
|
|
|
<div class="content">
|
|
{@render children()}
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
### Layout Groups
|
|
|
|
```
|
|
src/routes/
|
|
├── (auth)/ # Group - no route segment
|
|
│ ├── +layout.svelte # Shared layout for auth pages
|
|
│ ├── login/
|
|
│ │ └── +page.svelte
|
|
│ └── register/
|
|
│ └── +page.svelte
|
|
├── (app)/ # Group - no route segment
|
|
│ ├── +layout.svelte # Shared layout for app pages
|
|
│ ├── dashboard/
|
|
│ │ └── +page.svelte
|
|
│ └── settings/
|
|
│ └── +page.svelte
|
|
└── +layout.svelte # Root layout
|
|
```
|
|
|
|
### Breaking Out of Layout
|
|
|
|
```svelte
|
|
<!-- src/routes/special/+page@.svelte -->
|
|
<!-- The @. means use root layout, not parent -->
|
|
<script lang="ts">
|
|
// This page uses +layout.svelte directly, skipping any nested layouts
|
|
</script>
|
|
```
|
|
|
|
---
|
|
|
|
## 🔀 Navigation
|
|
|
|
### Programmatic Navigation
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import { goto, invalidate, invalidateAll } from '$app/navigation';
|
|
import { page } from '$app/stores';
|
|
|
|
// Navigate to a page
|
|
async function navigate() {
|
|
await goto('/dashboard');
|
|
}
|
|
|
|
// Navigate with options
|
|
async function navigateWithState() {
|
|
await goto('/search', {
|
|
replaceState: true, // Replace instead of push
|
|
keepFocus: true, // Keep focus on current element
|
|
noScroll: true, // Don't scroll to top
|
|
state: { query: 'test' } // Pass state
|
|
});
|
|
}
|
|
|
|
// Invalidate load functions
|
|
async function refresh() {
|
|
await invalidate('/api/posts'); // Rerun loads that depend on this
|
|
// or
|
|
await invalidateAll(); // Rerun all load functions
|
|
}
|
|
</script>
|
|
|
|
<!-- Current page info -->
|
|
<p>Current path: {$page.url.pathname}</p>
|
|
<p>Query: {$page.url.searchParams.get('q')}</p>
|
|
```
|
|
|
|
### Link Prefetching
|
|
|
|
```svelte
|
|
<!-- Preload on hover (default) -->
|
|
<a href="/about">About</a>
|
|
|
|
<!-- Preload on viewport entry -->
|
|
<a href="/posts" data-sveltekit-preload-data="hover">Posts</a>
|
|
|
|
<!-- Disable preloading -->
|
|
<a href="/external" data-sveltekit-preload-data="off">External</a>
|
|
|
|
<!-- Preload immediately when link is rendered -->
|
|
<a href="/dashboard" data-sveltekit-preload-data="eager">Dashboard</a>
|
|
```
|
|
|
|
---
|
|
|
|
## ⚠️ Error Handling
|
|
|
|
### Error Pages
|
|
|
|
```svelte
|
|
<!-- src/routes/+error.svelte -->
|
|
<script lang="ts">
|
|
import { page } from '$app/stores';
|
|
</script>
|
|
|
|
<div class="error">
|
|
<h1>{$page.status}</h1>
|
|
<p>{$page.error?.message}</p>
|
|
<a href="/">Go home</a>
|
|
</div>
|
|
|
|
<!-- src/routes/blog/+error.svelte -->
|
|
<!-- More specific error page for /blog routes -->
|
|
<script lang="ts">
|
|
import { page } from '$app/stores';
|
|
</script>
|
|
|
|
<div class="blog-error">
|
|
<h1>Blog Post Not Found</h1>
|
|
<p>{$page.error?.message}</p>
|
|
<a href="/blog">View all posts</a>
|
|
</div>
|
|
```
|
|
|
|
### Throwing Errors
|
|
|
|
```typescript
|
|
// In load functions or actions
|
|
import { error, redirect } from '@sveltejs/kit';
|
|
|
|
export const load = async ({ params }) => {
|
|
const post = await getPost(params.id);
|
|
|
|
if (!post) {
|
|
error(404, {
|
|
message: 'Post not found',
|
|
code: 'POST_NOT_FOUND'
|
|
});
|
|
}
|
|
|
|
if (post.draft) {
|
|
redirect(303, '/blog');
|
|
}
|
|
|
|
return { post };
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 🔧 Hooks
|
|
|
|
### Server Hooks (hooks.server.ts)
|
|
|
|
```typescript
|
|
// src/hooks.server.ts
|
|
import type { Handle, HandleFetch, HandleServerError } from '@sveltejs/kit';
|
|
|
|
// Runs for every request
|
|
export const handle: Handle = async ({ event, resolve }) => {
|
|
// Authentication
|
|
const session = event.cookies.get('session');
|
|
if (session) {
|
|
event.locals.user = await getUserFromSession(session);
|
|
}
|
|
|
|
// Performance timing
|
|
const start = Date.now();
|
|
|
|
const response = await resolve(event, {
|
|
// Transform HTML
|
|
transformPageChunk: ({ html }) => html.replace('%theme%', 'dark')
|
|
});
|
|
|
|
// Log request duration
|
|
console.log(`${event.request.method} ${event.url.pathname} - ${Date.now() - start}ms`);
|
|
|
|
return response;
|
|
};
|
|
|
|
// Customize server-side fetch
|
|
export const handleFetch: HandleFetch = async ({ request, fetch }) => {
|
|
// Add auth header to internal API calls
|
|
if (request.url.startsWith('https://api.internal.com')) {
|
|
request.headers.set('Authorization', `Bearer ${API_KEY}`);
|
|
}
|
|
|
|
return fetch(request);
|
|
};
|
|
|
|
// Handle unexpected errors
|
|
export const handleError: HandleServerError = async ({ error, event }) => {
|
|
console.error('Server error:', error);
|
|
|
|
// Report to error tracking service
|
|
await reportError(error, event);
|
|
|
|
return {
|
|
message: 'An unexpected error occurred',
|
|
code: 'INTERNAL_ERROR'
|
|
};
|
|
};
|
|
```
|
|
|
|
### Client Hooks (hooks.client.ts)
|
|
|
|
```typescript
|
|
// src/hooks.client.ts
|
|
import type { HandleClientError } from '@sveltejs/kit';
|
|
|
|
export const handleError: HandleClientError = async ({ error, event }) => {
|
|
console.error('Client error:', error);
|
|
|
|
// Report to error tracking
|
|
await reportClientError(error);
|
|
|
|
return {
|
|
message: 'Something went wrong',
|
|
code: 'CLIENT_ERROR'
|
|
};
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 📋 Best Practices
|
|
|
|
### ✅ Do:
|
|
- Use `+page.server.ts` for sensitive data
|
|
- Return typed data from load functions
|
|
- Use `use:enhance` for progressive enhancement
|
|
- Handle loading and error states
|
|
- Use route groups for layout organization
|
|
- Validate form data server-side
|
|
|
|
### ❌ Don't:
|
|
- Put secrets in `+page.ts` (runs on client)
|
|
- Forget to await `parent()` in child loads
|
|
- Block navigation with slow loads
|
|
- Ignore accessibility in forms
|
|
- Hardcode URLs (use `$page` store)
|