# SvelteKit + shadcn-svelte

You are working in a SvelteKit 2 project with Svelte 5, TypeScript, shadcn-svelte components built on Bits UI, and Tailwind CSS v4. Write code for the versions in `package.json`, not the ones in your training data: Svelte 5 replaced most of Svelte 4's syntax with runes, snippets and event attributes, and the old syntax only survives in legacy mode.

## Before you write code

- Read `package.json` for `svelte`, `@sveltejs/kit`, `bits-ui` (or `@ark-ui/svelte`) and `tailwindcss`, and `svelte.config.js` for the adapter, aliases and any `experimental` flags.
- Read `components.json` for the aliases (usually `$lib/components/ui` and `$lib/utils`), and look in the UI folder before adding a component: it may exist, with local changes to keep.
- Use the generated types: `./$types` in every route file. Run `npx svelte-kit sync` (or the dev server) after adding routes so they exist.

## Svelte 5 runes

- State: `let count = $state(0)`. Objects and arrays become deeply reactive proxies; mutate them directly. Use `$state.raw` for large data you replace whole (API results, long lists).
- Derived values: `let total = $derived(items.length)`, or `$derived.by(() => { … })` for several lines. Never compute state inside `$effect`.
- `$effect` is for side effects outside Svelte (DOM APIs, timers, subscriptions, analytics); return a cleanup function. It never runs on the server, so it can't load page data.
- Props come from one `$props()` call, with defaults and the rest spread:

```svelte
<script lang="ts">
  import type { Snippet } from "svelte";
  import type { HTMLButtonAttributes } from "svelte/elements";

  interface Props extends HTMLButtonAttributes {
    value?: string;
    onvaluechange?: (value: string) => void;
    children?: Snippet;
  }

  let { value = $bindable(""), onvaluechange, children, ...rest }: Props = $props();
  const id = $props.id();
</script>

<button {...rest} {id} onclick={() => onvaluechange?.(value)}>
  {@render children?.()}
</button>
```

- Mark a prop `$bindable()` only when the parent needs `bind:value`; otherwise pass a callback prop. `$props.id()` gives ids that match between server and client.
- `$inspect(value)` is for debugging: remove it before finishing.
- Shared reactive state lives in `.svelte.ts` modules as an object or class with `$state` fields; you can't export a `let` that you reassign. On the server, module state is shared by every request: keep per-user data in `event.locals`, load data or context (`createContext` returns a typed getter and setter).
- Use `SvelteMap`, `SvelteSet`, `SvelteURL` and `MediaQuery` from `svelte/reactivity` instead of wrapping built-ins by hand.

## Templates

- Events are attributes: `onclick={save}`, `oninput`, `onsubmit`. There are no modifiers: call `event.preventDefault()` in the handler.
- Components raise events through callback props (`onvaluechange`), not dispatched events.
- Content is passed as snippets: `children` rendered with `{@render children?.()}`, named ones declared with `{#snippet row(item)}…{/snippet}` and typed `Snippet<[Item]>`. A component stored in a variable renders as `<Icon />` directly.
- Attach DOM behavior with `{@attach tooltip(text)}`: attachments re-run when their state changes and pass through spread props. Wrap an existing action with `fromAction` from `svelte/attachments` rather than writing a new `use:` action.
- `class` takes arrays and objects (`class={["card", selected && "ring-2", { "opacity-50": disabled }]}`); merge with a caller's class through `cn()`.
- Key every list: `{#each items as item (item.id)}`. Catch rendering errors in a region with `<svelte:boundary>` and a `failed` snippet.
- Intercept a binding with function bindings: `bind:value={() => value, (next) => (value = next.trim())}`.

## Routes and loading

- Files in `src/routes`: `+page.svelte`, `+layout.svelte`, `+page.ts` (universal load: runs on the server and in the browser), `+page.server.ts` (server only: database, secrets, cookies), `+server.ts` (endpoints), `+error.svelte`. Groups are `(app)`, params `[id]`, optional `[[lang]]`, rest `[...path]`.
- Pages read their props with the generated types; layouts render `children`:

```svelte
<script lang="ts">
  import type { PageProps } from "./$types";
  let { data, form }: PageProps = $props();
</script>
```

- Write loads as `export const load: PageServerLoad = async ({ params, locals, depends }) => { … }`. In universal loads use the `fetch` argument, never the global one: it forwards cookies and doesn't refetch on hydration. Never fetch your own `+server.ts` from a server load: call the function it would call.
- Return only what the page renders, never whole rows with private fields. To stream slow data, return its promise un-awaited as a property of a server load and render it with `{#await data.comments}`.
- `error(404, "Not found")` and `redirect(303, "/login")` from `@sveltejs/kit` throw by themselves: call them without `throw`, and outside any `try` that would swallow them.
- Refresh data with `depends("app:posts")` plus `invalidate("app:posts")`, or `invalidateAll()`, from `$app/navigation`.
- Read the current page from `$app/state`: `page.url`, `page.params`, `page.data`, `page.form`, `navigating`. It replaces `$app/stores`.
- Navigate with plain `<a href>`; use `goto` for programmatic navigation inside the app only. Set `prerender`, `ssr` and `csr` as exports of `+page.ts` or `+layout.ts`.

## Forms and endpoints

- Mutations are form actions in `+page.server.ts`:

```ts
import { fail, redirect } from "@sveltejs/kit";
import { z } from "zod";
import type { Actions } from "./$types";

const Post = z.object({ title: z.string().trim().min(1).max(200) });

export const actions = {
  create: async ({ request, locals }) => {
    if (!locals.user) redirect(303, "/login");
    const parsed = Post.safeParse(Object.fromEntries(await request.formData()));
    if (!parsed.success) {
      return fail(400, { errors: z.flattenError(parsed.error).fieldErrors });
    }
    await createPost(locals.user.id, parsed.data); // your data layer
  },
} satisfies Actions;
```

- Post to them with `<form method="POST" action="?/create" use:enhance>` (`enhance` from `$app/forms`), so the form works before JavaScript loads. After a successful action SvelteKit reruns the page's loads; customize with the `use:enhance` callback and call `update()`.
- Return errors with `fail(status, data)` and read them from `form`; never echo passwords back.
- `+server.ts` endpoints export `GET`, `POST` and the rest, typed `RequestHandler`, and return `json(…)` or a `Response`. Use them for webhooks and outside clients, not your own pages.
- Remote functions (`query`, `form`, `command` from `$app/server` in `*.remote.ts` files) are experimental: use them only when `svelte.config.js` enables them, and validate their arguments with a schema too.

## Server, auth and environment

- `src/hooks.server.ts` exports `handle`, which reads the session cookie, sets `event.locals.user` and calls `resolve(event)`. Type it in `App.Locals` in `src/app.d.ts`; compose several hooks with `sequence` from `@sveltejs/kit/hooks`; log in `handleError` and return a safe message.
- A check in `+layout.server.ts` doesn't protect the page's actions or endpoints, and page loads don't wait for it. Check `locals.user` in every load, action and endpoint that touches private data.
- Set cookies with an explicit path: `cookies.set("session", id, { path: "/", httpOnly: true, secure: true, sameSite: "lax" })`.
- Secrets come from `$env/static/private` (build time) or `$env/dynamic/private` (runtime), which only server modules can import. Browser values need the `PUBLIC_` prefix and `$env/static/public`. Keep server-only code in `$lib/server/` or `*.server.ts` files: SvelteKit refuses to bundle them for the browser.

## shadcn-svelte components

- Add components with the CLI, `npx shadcn-svelte@latest add dialog`, never from memory. The source lands in the UI folder for you to own and edit; import it from the folder index as the generated files do (`import * as Dialog from "$lib/components/ui/dialog/index.js"`).
- Variants are defined with `tv()` from `tailwind-variants` in the component file (`buttonVariants`); merge classes with `cn()` from `$lib/utils`.
- Bits UI has no `asChild`. To render your own element, use the `child` snippet and spread its props; to only look like a button, give the trigger `class={buttonVariants({ variant: "outline" })}`.

```svelte
<Dialog.Root bind:open>
  <Dialog.Trigger>
    {#snippet child({ props })}
      <Button {...props} variant="outline">Edit profile</Button>
    {/snippet}
  </Dialog.Trigger>
  <Dialog.Content>
    <Dialog.Title>Edit profile</Dialog.Title>
    <Dialog.Description>Changes save when you press Save.</Dialog.Description>
  </Dialog.Content>
</Dialog.Root>
```

- Control roots with `bind:open` and `bind:value`; get the element with `bind:ref`. Every dialog has a title, every input a label.
- Style states with the data attributes Bits UI sets (`data-[state=open]:`, `data-disabled:`).
- Dark mode: `<ModeWatcher />` from `mode-watcher` in the root `+layout.svelte`, which sets the `dark` class on `<html>`; switch with its `toggleMode()` or `setMode("dark")`.

## Tailwind CSS v4

- `@tailwindcss/vite` runs before SvelteKit: `plugins: [tailwindcss(), sveltekit()]`. The stylesheet (`@import "tailwindcss";`) is `src/app.css`, imported once in the root `+layout.svelte`. No `tailwind.config.js` and no PostCSS setup for Tailwind.
- Tokens are OKLCH variables in `:root` and `.dark`, mapped in `@theme inline { --color-background: var(--background); }`, with `@custom-variant dark (&:is(.dark *));`. Use `bg-background` and `text-muted-foreground`, never raw palette colors or `hsl(var(--x))`.
- A `<style>` block that uses `@apply` needs `@reference "../app.css";` (the relative path to your stylesheet). Prefer utilities in markup.

## If the project uses Ark UI

When `package.json` has `@ark-ui/svelte` instead of `bits-ui`, import parts per component (`import { Dialog } from "@ark-ui/svelte/dialog"`), compose `Dialog.Root` and its parts, use `bind:open`, and render your own element through the `asChild` snippet, which receives a props function: `{#snippet asChild(props)}<button {...props()}>Open</button>{/snippet}`. Never mix Ark and Bits UI parts in one component.

## Do not

- Do not write `export let`, `$:`, `on:click`, `|preventDefault`, `<slot>`, `$$props`, `$$restProps`, `createEventDispatcher`, `beforeUpdate`, `afterUpdate` or `<svelte:component>`, and don't use stores for component state.
- Do not import `$app/stores` or read `$page`; do not put secrets in `+page.ts` or a `PUBLIC_` variable.
- Do not load data in `onMount` or `$effect` when a load function can, or derive state in an effect.
- Do not use `asChild` with Bits UI, add `tailwind.config.js`, or write `hsl(var(--x))` tokens.

## Before you finish

- Run the project's check script (`svelte-kit sync && svelte-check`) and the linter: zero errors and no accessibility warnings.
- Submit every form you touched with JavaScript disabled, then with it on.
- Load each page with a hard refresh: data is in the server HTML and the console shows no hydration or state mutation warnings.
- Call actions and endpoints without a session: expect a redirect or 401, never private data. Check light and dark mode.
