plugins/guild/skills/svelte-best-practices/SKILL.md
Pre-loaded Svelte 5 / SvelteKit best practices for the developer-svelte agent. Covers state management strategies, performance optimization, accessibility, SEO, TypeScript usage, testing (vitest + playwright), and error handling. Load alongside the other svelte-* skills before implementing anything non-trivial. Trigger phrases include "svelte best practices", "svelte performance", "svelte accessibility", "svelte typescript", "svelte testing", "sveltekit seo", "sveltekit error handling".
npx skillsauth add hirogakatageri/hirokata svelte-best-practicesInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Cross-cutting guidance: how to choose the right primitives, keep apps fast and accessible, and write code that doesn't break in production.
Pick the smallest scope that works:
let x = $state(0) inside a .svelte file. Default for anything not shared..svelte.ts exporting an object with mutable fields (export const counter = $state({ value: 0 })). Default for cross-component state in a feature.setContext / getContext for tree-scoped state where multiple components need a per-instance shared object (e.g., a form, a dropdown). Pair context with a reactive $state object so the value can update.page.url.searchParams, write via goto('?x=1', { replaceState: true, keepFocus: true, noScroll: true }).+server / hooks; read in server load.Avoid:
svelte-core "Sharing State Across Modules").writable stores with runes in the same feature unless you have a migration reason.Three rules:
$lib/server/** is server-only — importing it from a +page.svelte or any *.client.* is a build error.+page.server.ts, +layout.server.ts, +server.ts, hooks.server.ts are server-only.$env/static/private and $env/dynamic/private are server-only.When unsure, check whether a secret could leak. If it could, it goes server-side.
load for non-critical data. Wrap in {#await} blocks. Keeps TTFB low.await parent() unless you actually need parent data; placement of await parent() controls whether you cause a waterfall.fetch in load: Always use the load event's fetch (not the global). It coalesces SSR + hydration responses and forwards cookies.data-sveltekit-preload-data="hover" on <a> (or an ancestor like <body>) starts the load on hover. Use "tap" for mobile-heavy apps.import() inside an {#await} block.prerender = true for pages whose content is build-time stable. This produces zero-JS-by-default pages (when csr is also false) or hydration-only pages.prerender = 'auto' is a useful fallback during incremental adoption.csr = false for static content pages (docs, marketing) — eliminates the JS bundle.ssr = false only for routes that genuinely can't render server-side (auth-walled dashboards backed by client-only APIs). The cost is a flash of empty content.$derived is lazy and skips downstream updates when the value is referentially identical. Don't try to manually memoize; let the compiler do it.$state.raw for large arrays/objects you replace wholesale. Avoids the proxy cost.$effect for things that should be $derived. Effects run on every dependency change with all the cleanup overhead; deriveds are pull-based.vite build --mode=analyze (with rollup-plugin-visualizer) to inspect chunks.precompress: true on adapters that support it.The Svelte compiler emits a11y warnings — treat them as errors. They catch real issues:
a11y_click_events_have_key_events, a11y_no_static_element_interactions: if you put onclick on a non-button, you must also handle keyboard.a11y_label_has_associated_control: pair every <label> with for or wrap.a11y_missing_attribute: <img> needs alt, <a> needs href.a11y_autofocus: don't auto-focus — disorients screen reader users.SvelteKit doesn't move focus on client-side navigation by default — set data-sveltekit-keepfocus carefully and consider a "skip to content" link plus an <h1> per page that focus moves to. Use afterNavigate to manage focus deliberately on SPA-style pages.
aria-current="page"For nav links pointing at the current route:
<a href="/" aria-current={page.url.pathname === '/' ? 'page' : undefined}>Home</a>
For preserving the highlight during a slow navigation, $state.eager(page.url.pathname) reflects user intent immediately.
import { prefersReducedMotion } from 'svelte/motion';
$: duration = prefersReducedMotion.current ? 0 : 300;
<svelte:head> per page (<title>, <meta name="description">, OG tags, canonical URL).+server.ts at /sitemap.xml returning XML with Content-Type: application/xml.<article>, <nav>, <main>) — Svelte's a11y warnings nudge you toward this anyway.sv create with the TypeScript option scaffolds correct tsconfig.json (inherits from .svelte-kit/tsconfig.json which Kit generates). Don't override paths blindly — Kit relies on its aliases.
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
count?: number;
children?: Snippet;
onsave?: (value: string) => void;
}
let { title, count = 0, children, onsave }: Props = $props();
</script>
<script lang="ts" generics="T extends { id: string }">
let { items, render }: { items: T[]; render: Snippet<[T]> } = $props();
</script>
{#each items as item (item.id)}
{@render render(item)}
{/each}
PageProps, LayoutProps, PageServerLoad, PageLoad, LayoutServerLoad, LayoutLoad, RequestHandler, Actions — all from ./$types. Don't write them by hand; svelte-kit sync regenerates them.
src/app.d.ts:
declare global {
namespace App {
interface Locals { user?: { id: string; email: string } }
interface PageData {}
interface PageState { showModal?: boolean }
interface Platform {}
interface Error { code?: string }
}
}
export {};
Locals is the type of event.locals. PageState types page.state (shallow routing). Platform types event.platform (Cloudflare bindings, etc.).
vitest for unit/component testsUse @testing-library/svelte plus vitest:
// counter.test.ts
import { render, fireEvent } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import Counter from './Counter.svelte';
test('increments', async () => {
const { getByRole } = render(Counter, { props: { initial: 0 } });
const button = getByRole('button');
await fireEvent.click(button);
expect(button.textContent).toContain('1');
});
For pure logic in .svelte.ts modules, vitest can import them directly — runes work in test mode when the vitest config includes the Svelte plugin.
playwright for E2Etests/example.test.ts:
import { expect, test } from '@playwright/test';
test('home page', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});
Run via npm run test after sv add playwright. Playwright spins up the dev or preview server automatically.
@testing-library/svelte.error(404, '...'), fail(400, ...)): user-facing, status-coded. Use them liberally.handleError. Never expose internals to the user.// +page.server.ts
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params, locals }) => {
const post = await db.getPost(params.slug);
if (!post) error(404, { message: 'post not found' }); // typed message
if (!locals.user && post.private) error(401, 'login required');
return { post };
};
For typed errors, augment App.Error in app.d.ts:
namespace App {
interface Error { message: string; code?: 'NOT_FOUND' | 'FORBIDDEN' }
}
+error.svelteShow the error from page.error and consider linking back home or to the previous page. Keep production messages generic; rely on logs for diagnostics.
Return validation problems with fail(status, data) instead of throwing — the form prop preserves user input.
if (!email) return fail(400, { email, missing: true });
<svelte:boundary>For component-level recovery (e.g., a flaky third-party widget), wrap it in a boundary with a failed snippet that includes a reset() button. Don't use boundaries to hide bugs — log via onerror={(err) => report(err)}.
<svelte:options runes={true} /> forces runes mode for that component when the project default is legacy.sv migrate svelte-5 for codemods on incoming Svelte 4 code.console.log left in production pathspnpm check / npm run check passes)<svelte:head> with title + descriptionfail(...) for validation, throw error(...) for unexpected{#each} over reorderable lists are keyed./$types) are imported, not hand-writtensvelte-check passesdevelopment
This skill should be used when the user reports an error, bug, or unexpected behavior and wants it diagnosed and fixed. Trigger on phrases like "check this error", "check this bug", "here's an error", "here's a bug", "I have an error", "I have a bug", "found a bug", "got an error", "debug this", "this is broken", "fix this error", "verify and fix", or any message that includes a stack trace or error output. Runs a structured workflow: gather context, investigate configured log/code sources, report root cause with ranked solutions, then apply a test-driven fix.
testing
This skill should be used when the user says "check svelte env vars", "check environment variables", "validate env vars", "check env var patterns", "audit environment variables", "audit env vars", "check SvelteKit env", "svelte env check", or any phrase asking to audit or validate SvelteKit environment variable usage patterns.
data-ai
Internal skill used by the session-tracker logger agent to append a session entry to .logs/YYYY-MM-DD-log.md, creating the file and directory if needed. Not user-invocable.
data-ai
Internal skill used by the session-tracker logger agent to query git for committed and uncommitted changes in the past 28 hours. Not user-invocable.