// Config Record
>.cursor/rules — SvelteKit + TypeScript
Cursor MDC project rules for SvelteKit and TypeScript with Svelte 5 runes, form actions, and server/client boundary patterns.
author:
dotmd Team
license:CC0
published:Feb 23, 2026
// Installation
>Add this file to your project repository:
- Cursor--path=
.cursor/rules/
// File Content
.cursor/rules (MDC)
1---2description: SvelteKit + TypeScript project conventions, patterns, and best practices3globs:4alwaysApply: true5---67# SvelteKit + TypeScript89## Svelte 5 Runes1011Use runes for all reactive state. Never use legacy `$:` reactive declarations or `let` export props.1213```svelte14<script lang="ts">15 // State16 let count = $state(0);17 let items = $state<string[]>([]);1819 // Derived values (replaces $: computed)20 let doubled = $derived(count * 2);21 let total = $derived.by(() => {22 return items.reduce((sum, item) => sum + item.length, 0);23 });2425 // Side effects (replaces $: statements with side effects)26 $effect(() => {27 console.log(`count changed to ${count}`);28 return () => {29 // cleanup runs before next effect and on destroy30 };31 });32</script>33```3435### Component Props3637Use `$props()` with TypeScript interfaces. Never use `export let`.3839```svelte40<script lang="ts">41 interface Props {42 title: string;43 description?: string;44 children: import('svelte').Snippet;45 onclick?: (e: MouseEvent) => void;46 }4748 let { title, description = '', children, onclick }: Props = $props();49</script>5051<div {onclick}>52 <h2>{title}</h2>53 {#if description}54 <p>{description}</p>55 {/if}56 {@render children()}57</div>58```5960### Snippets Over Slots6162Use `{#snippet}` and `{@render}` instead of `<slot>`. Slots are legacy.6364```svelte65<!-- Parent -->66<Card>67 {#snippet header()}68 <h2>Title</h2>69 {/snippet}70 {#snippet default()}71 <p>Content</p>72 {/snippet}73</Card>7475<!-- Card.svelte -->76<script lang="ts">77 import type { Snippet } from 'svelte';7879 let { header, children }: { header?: Snippet; children: Snippet } = $props();80</script>8182<div class="card">83 {#if header}{@render header()}{/if}84 {@render children()}85</div>86```8788## File-Based Routing8990### Route File Conventions9192```93src/routes/94├── +page.svelte # Page component95├── +page.ts # Universal load (runs server + client)96├── +page.server.ts # Server-only load + form actions97├── +layout.svelte # Layout wrapper98├── +layout.ts # Layout load (universal)99├── +layout.server.ts # Layout load (server-only)100├── +error.svelte # Error boundary101├── +server.ts # API endpoint (GET, POST, PUT, DELETE)102├── blog/103│ ├── +page.svelte # /blog104│ └── [slug]/105│ ├── +page.svelte # /blog/:slug106│ └── +page.server.ts107└── (app)/ # Route group (no URL segment)108 ├── +layout.svelte # Shared layout for grouped routes109 └── dashboard/110 └── +page.svelte # /dashboard111```112113- **Parenthesized directories** `(group)` create layout groups without affecting the URL.114- **`[[optional]]`** for optional parameters. **`[...rest]`** for catch-all.115- Place shared components in `src/lib/components/`, not in routes.116117## Generated Types118119Always import types from `./$types`. These are auto-generated per route file.120121```typescript122// +page.server.ts123import type { PageServerLoad, Actions } from './$types';124125export const load: PageServerLoad = async ({ params, locals, depends }) => {126 depends('app:posts');127128 const post = await db.getPost(params.slug);129 if (!post) error(404, 'Post not found');130131 return { post };132};133```134135```svelte136<!-- +page.svelte -->137<script lang="ts">138 import type { PageData } from './$types';139140 let { data }: { data: PageData } = $props();141</script>142143<h1>{data.post.title}</h1>144```145146Never manually type load function returns. The generated types flow from `load` → `data` prop automatically.147148## Server vs Client Boundary149150### Load Functions151152- **`+page.ts` / `+layout.ts`** — Universal load. Runs on server during SSR, then on client during navigation. No access to DB, filesystem, or secrets. Use `fetch` (SvelteKit deduplicates and handles cookies).153- **`+page.server.ts` / `+layout.server.ts`** — Server-only load. Access DB, env vars, internal services. Return data must be serializable (no functions, classes, or dates — use `devalue` for complex types).154155Prefer server load unless the data is needed client-side during navigation without a round-trip.156157### Form Actions158159Use form actions for mutations. They work without JavaScript and enhance progressively.160161```typescript162// +page.server.ts163import type { Actions } from './$types';164import { fail, redirect } from '@sveltejs/kit';165166export const actions: Actions = {167 create: async ({ request, locals }) => {168 const formData = await request.formData();169 const title = formData.get('title')?.toString();170171 if (!title || title.length < 3) {172 return fail(400, { title, error: 'Title must be at least 3 characters' });173 }174175 const post = await db.createPost({ title, userId: locals.user.id });176 redirect(303, `/blog/${post.slug}`);177 },178179 delete: async ({ request, locals }) => {180 const formData = await request.formData();181 const id = formData.get('id')?.toString();182 await db.deletePost(id, locals.user.id);183 return { success: true };184 }185};186```187188```svelte189<script lang="ts">190 import { enhance } from '$app/forms';191 import type { ActionData } from './$types';192193 let { form }: { form: ActionData } = $props();194</script>195196<form method="POST" action="?/create" use:enhance>197 <input name="title" value={form?.title ?? ''} />198 {#if form?.error}199 <p class="error">{form.error}</p>200 {/if}201 <button>Create</button>202</form>203```204205Always use `use:enhance` for progressive enhancement. Customize it for optimistic UI:206207```svelte208<form method="POST" action="?/delete" use:enhance={() => {209 // Optimistic: hide item immediately210 deleting = true;211 return async ({ update }) => {212 await update(); // rerun load functions213 deleting = false;214 };215}}>216```217218### API Routes219220Use `+server.ts` only for non-HTML responses (JSON APIs, webhooks, file downloads). Prefer form actions for HTML form submissions.221222```typescript223// src/routes/api/posts/+server.ts224import type { RequestHandler } from './$types';225import { json, error } from '@sveltejs/kit';226227export const GET: RequestHandler = async ({ url, locals }) => {228 const limit = Number(url.searchParams.get('limit') ?? 20);229 const posts = await db.getPosts({ limit, userId: locals.user.id });230 return json(posts);231};232```233234## Error Handling235236### Expected vs Unexpected Errors237238```typescript239import { error } from '@sveltejs/kit';240241// Expected: known conditions — show to user242if (!post) error(404, { message: 'Post not found' });243244// Unexpected: bugs — SvelteKit catches, shows generic 500245// Just throw or let it propagate; don't wrap in error()246```247248### Error Pages249250```svelte251<!-- +error.svelte -->252<script lang="ts">253 import { page } from '$app/state';254</script>255256<h1>{page.status}</h1>257<p>{page.error?.message}</p>258```259260Place `+error.svelte` at the layout level that should catch errors. The root `+error.svelte` catches everything except errors in the root `+layout.svelte` (which need `src/error.html`).261262## Imports and `$lib`263264```typescript265// Always use $lib alias — never relative paths out of routes266import Button from '$lib/components/Button.svelte';267import { formatDate } from '$lib/utils';268import { db } from '$lib/server/db'; // $lib/server/ is server-only269```270271Files in `$lib/server/` are blocked from client imports by SvelteKit. Put DB clients, auth logic, and secrets there.272273## Environment Variables274275```typescript276// Server-only (private) — prefix not required277import { DATABASE_URL, API_SECRET } from '$env/static/private';278279// Client-safe — must be prefixed with PUBLIC_280import { PUBLIC_API_URL } from '$env/static/public';281282// Dynamic (read at runtime, not build-time)283import { env } from '$env/dynamic/private';284const secret = env.API_SECRET;285```286287Never import `$env/static/private` in client code. SvelteKit will error at build time.288289## Hooks290291### Server Hooks (`src/hooks.server.ts`)292293```typescript294import type { Handle } from '@sveltejs/kit';295296export const handle: Handle = async ({ event, resolve }) => {297 const session = await getSession(event.cookies);298 event.locals.user = session?.user ?? null;299300 const response = await resolve(event);301 return response;302};303```304305Compose multiple hooks with `sequence`:306307```typescript308import { sequence } from '@sveltejs/kit/hooks';309export const handle = sequence(authHandle, loggingHandle);310```311312### Type `locals` in `app.d.ts`313314```typescript315// src/app.d.ts316declare global {317 namespace App {318 interface Locals {319 user: { id: string; name: string } | null;320 }321 interface Error {322 message: string;323 code?: string;324 }325 interface PageState {}326 }327}328export {};329```330331## Styling332333Styles in `<style>` are scoped to the component by default. Use `:global()` sparingly.334335```svelte336<style>337 /* Scoped to this component */338 .card { padding: 1rem; }339340 /* Target child component wrappers */341 .container :global(.child-class) { margin: 0; }342</style>343```344345For Tailwind CSS, configure `@tailwindcss/vite` in `vite.config.ts` and import the stylesheet in `+layout.svelte`. Use `@apply` in component styles only when extracting repeated utility patterns.346347## Navigation and State348349```svelte350<script lang="ts">351 import { goto, invalidate, invalidateAll } from '$app/navigation';352 import { page } from '$app/state';353354 // Navigate programmatically355 function handleClick() {356 goto('/dashboard');357 }358359 // Rerun specific load functions360 async function refresh() {361 await invalidate('app:posts'); // matches depends('app:posts')362 await invalidateAll(); // rerun all load functions363 }364365 // Reactive access to page state366 let isActive = $derived(page.url.pathname === '/dashboard');367</script>368```369370Use `$app/state` (Svelte 5) instead of `$app/stores`. The `page` import from `$app/state` is a reactive object — no `$page` store subscription needed.371372## Testing373374Use `vitest` for unit/integration and `@playwright/test` for e2e.375376```typescript377// src/lib/utils.test.ts — unit tests with vitest378import { describe, it, expect } from 'vitest';379import { formatDate } from './utils';380381describe('formatDate', () => {382 it('formats ISO dates', () => {383 expect(formatDate('2025-01-15')).toBe('Jan 15, 2025');384 });385});386```387388```typescript389// e2e/blog.test.ts — Playwright390import { expect, test } from '@playwright/test';391392test('create post flow', async ({ page }) => {393 await page.goto('/blog/new');394 await page.fill('input[name="title"]', 'Test Post');395 await page.click('button[type="submit"]');396 await expect(page).toHaveURL(/\/blog\/.+/);397});398```399400Configure Vitest in `vite.config.ts` using `@sveltejs/kit/vite`:401402```typescript403import { sveltekit } from '@sveltejs/kit/vite';404import { defineConfig } from 'vitest/config';405406export default defineConfig({407 plugins: [sveltekit()],408 test: {409 include: ['src/**/*.test.ts'],410 },411});412```413414## Key Principles4154161. **Server-first data loading.** Default to `+page.server.ts`. Use universal load only when client-side navigation needs direct data access.4172. **Form actions for mutations.** Not `fetch` to API routes. Form actions give you progressive enhancement, validation returns, and redirect handling for free.4183. **Let SvelteKit handle the network.** Use `fetch` in universal load (not raw `fetch` in components). Use `invalidate` to refresh data, not manual refetching.4194. **Type safety flows from routing.** Import from `./$types`, type `app.d.ts`, and let the generated types do the work.4205. **Colocate server logic.** `+page.server.ts` next to `+page.svelte`. `$lib/server/` for shared server utilities. Don't scatter server code.421