dotmd
// 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
    .cursor/rules/
// File Content
.cursor/rules (MDC)
1---
2description: SvelteKit + TypeScript project conventions, patterns, and best practices
3globs:
4alwaysApply: true
5---
6
7# SvelteKit + TypeScript
8
9## Svelte 5 Runes
10
11Use runes for all reactive state. Never use legacy `$:` reactive declarations or `let` export props.
12
13```svelte
14<script lang="ts">
15 // State
16 let count = $state(0);
17 let items = $state<string[]>([]);
18
19 // 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 });
24
25 // 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 destroy
30 };
31 });
32</script>
33```
34
35### Component Props
36
37Use `$props()` with TypeScript interfaces. Never use `export let`.
38
39```svelte
40<script lang="ts">
41 interface Props {
42 title: string;
43 description?: string;
44 children: import('svelte').Snippet;
45 onclick?: (e: MouseEvent) => void;
46 }
47
48 let { title, description = '', children, onclick }: Props = $props();
49</script>
50
51<div {onclick}>
52 <h2>{title}</h2>
53 {#if description}
54 <p>{description}</p>
55 {/if}
56 {@render children()}
57</div>
58```
59
60### Snippets Over Slots
61
62Use `{#snippet}` and `{@render}` instead of `<slot>`. Slots are legacy.
63
64```svelte
65<!-- Parent -->
66<Card>
67 {#snippet header()}
68 <h2>Title</h2>
69 {/snippet}
70 {#snippet default()}
71 <p>Content</p>
72 {/snippet}
73</Card>
74
75<!-- Card.svelte -->
76<script lang="ts">
77 import type { Snippet } from 'svelte';
78
79 let { header, children }: { header?: Snippet; children: Snippet } = $props();
80</script>
81
82<div class="card">
83 {#if header}{@render header()}{/if}
84 {@render children()}
85</div>
86```
87
88## File-Based Routing
89
90### Route File Conventions
91
92```
93src/routes/
94├── +page.svelte # Page component
95├── +page.ts # Universal load (runs server + client)
96├── +page.server.ts # Server-only load + form actions
97├── +layout.svelte # Layout wrapper
98├── +layout.ts # Layout load (universal)
99├── +layout.server.ts # Layout load (server-only)
100├── +error.svelte # Error boundary
101├── +server.ts # API endpoint (GET, POST, PUT, DELETE)
102├── blog/
103│ ├── +page.svelte # /blog
104│ └── [slug]/
105│ ├── +page.svelte # /blog/:slug
106│ └── +page.server.ts
107└── (app)/ # Route group (no URL segment)
108 ├── +layout.svelte # Shared layout for grouped routes
109 └── dashboard/
110 └── +page.svelte # /dashboard
111```
112
113- **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.
116
117## Generated Types
118
119Always import types from `./$types`. These are auto-generated per route file.
120
121```typescript
122// +page.server.ts
123import type { PageServerLoad, Actions } from './$types';
124
125export const load: PageServerLoad = async ({ params, locals, depends }) => {
126 depends('app:posts');
127
128 const post = await db.getPost(params.slug);
129 if (!post) error(404, 'Post not found');
130
131 return { post };
132};
133```
134
135```svelte
136<!-- +page.svelte -->
137<script lang="ts">
138 import type { PageData } from './$types';
139
140 let { data }: { data: PageData } = $props();
141</script>
142
143<h1>{data.post.title}</h1>
144```
145
146Never manually type load function returns. The generated types flow from `load` → `data` prop automatically.
147
148## Server vs Client Boundary
149
150### Load Functions
151
152- **`+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).
154
155Prefer server load unless the data is needed client-side during navigation without a round-trip.
156
157### Form Actions
158
159Use form actions for mutations. They work without JavaScript and enhance progressively.
160
161```typescript
162// +page.server.ts
163import type { Actions } from './$types';
164import { fail, redirect } from '@sveltejs/kit';
165
166export const actions: Actions = {
167 create: async ({ request, locals }) => {
168 const formData = await request.formData();
169 const title = formData.get('title')?.toString();
170
171 if (!title || title.length < 3) {
172 return fail(400, { title, error: 'Title must be at least 3 characters' });
173 }
174
175 const post = await db.createPost({ title, userId: locals.user.id });
176 redirect(303, `/blog/${post.slug}`);
177 },
178
179 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```
187
188```svelte
189<script lang="ts">
190 import { enhance } from '$app/forms';
191 import type { ActionData } from './$types';
192
193 let { form }: { form: ActionData } = $props();
194</script>
195
196<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```
204
205Always use `use:enhance` for progressive enhancement. Customize it for optimistic UI:
206
207```svelte
208<form method="POST" action="?/delete" use:enhance={() => {
209 // Optimistic: hide item immediately
210 deleting = true;
211 return async ({ update }) => {
212 await update(); // rerun load functions
213 deleting = false;
214 };
215}}>
216```
217
218### API Routes
219
220Use `+server.ts` only for non-HTML responses (JSON APIs, webhooks, file downloads). Prefer form actions for HTML form submissions.
221
222```typescript
223// src/routes/api/posts/+server.ts
224import type { RequestHandler } from './$types';
225import { json, error } from '@sveltejs/kit';
226
227export 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```
233
234## Error Handling
235
236### Expected vs Unexpected Errors
237
238```typescript
239import { error } from '@sveltejs/kit';
240
241// Expected: known conditions — show to user
242if (!post) error(404, { message: 'Post not found' });
243
244// Unexpected: bugs — SvelteKit catches, shows generic 500
245// Just throw or let it propagate; don't wrap in error()
246```
247
248### Error Pages
249
250```svelte
251<!-- +error.svelte -->
252<script lang="ts">
253 import { page } from '$app/state';
254</script>
255
256<h1>{page.status}</h1>
257<p>{page.error?.message}</p>
258```
259
260Place `+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`).
261
262## Imports and `$lib`
263
264```typescript
265// Always use $lib alias — never relative paths out of routes
266import Button from '$lib/components/Button.svelte';
267import { formatDate } from '$lib/utils';
268import { db } from '$lib/server/db'; // $lib/server/ is server-only
269```
270
271Files in `$lib/server/` are blocked from client imports by SvelteKit. Put DB clients, auth logic, and secrets there.
272
273## Environment Variables
274
275```typescript
276// Server-only (private) — prefix not required
277import { DATABASE_URL, API_SECRET } from '$env/static/private';
278
279// Client-safe — must be prefixed with PUBLIC_
280import { PUBLIC_API_URL } from '$env/static/public';
281
282// Dynamic (read at runtime, not build-time)
283import { env } from '$env/dynamic/private';
284const secret = env.API_SECRET;
285```
286
287Never import `$env/static/private` in client code. SvelteKit will error at build time.
288
289## Hooks
290
291### Server Hooks (`src/hooks.server.ts`)
292
293```typescript
294import type { Handle } from '@sveltejs/kit';
295
296export const handle: Handle = async ({ event, resolve }) => {
297 const session = await getSession(event.cookies);
298 event.locals.user = session?.user ?? null;
299
300 const response = await resolve(event);
301 return response;
302};
303```
304
305Compose multiple hooks with `sequence`:
306
307```typescript
308import { sequence } from '@sveltejs/kit/hooks';
309export const handle = sequence(authHandle, loggingHandle);
310```
311
312### Type `locals` in `app.d.ts`
313
314```typescript
315// src/app.d.ts
316declare 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```
330
331## Styling
332
333Styles in `<style>` are scoped to the component by default. Use `:global()` sparingly.
334
335```svelte
336<style>
337 /* Scoped to this component */
338 .card { padding: 1rem; }
339
340 /* Target child component wrappers */
341 .container :global(.child-class) { margin: 0; }
342</style>
343```
344
345For 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.
346
347## Navigation and State
348
349```svelte
350<script lang="ts">
351 import { goto, invalidate, invalidateAll } from '$app/navigation';
352 import { page } from '$app/state';
353
354 // Navigate programmatically
355 function handleClick() {
356 goto('/dashboard');
357 }
358
359 // Rerun specific load functions
360 async function refresh() {
361 await invalidate('app:posts'); // matches depends('app:posts')
362 await invalidateAll(); // rerun all load functions
363 }
364
365 // Reactive access to page state
366 let isActive = $derived(page.url.pathname === '/dashboard');
367</script>
368```
369
370Use `$app/state` (Svelte 5) instead of `$app/stores`. The `page` import from `$app/state` is a reactive object — no `$page` store subscription needed.
371
372## Testing
373
374Use `vitest` for unit/integration and `@playwright/test` for e2e.
375
376```typescript
377// src/lib/utils.test.ts — unit tests with vitest
378import { describe, it, expect } from 'vitest';
379import { formatDate } from './utils';
380
381describe('formatDate', () => {
382 it('formats ISO dates', () => {
383 expect(formatDate('2025-01-15')).toBe('Jan 15, 2025');
384 });
385});
386```
387
388```typescript
389// e2e/blog.test.ts — Playwright
390import { expect, test } from '@playwright/test';
391
392test('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```
399
400Configure Vitest in `vite.config.ts` using `@sveltejs/kit/vite`:
401
402```typescript
403import { sveltekit } from '@sveltejs/kit/vite';
404import { defineConfig } from 'vitest/config';
405
406export default defineConfig({
407 plugins: [sveltekit()],
408 test: {
409 include: ['src/**/*.test.ts'],
410 },
411});
412```
413
414## Key Principles
415
4161. **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