Svelte / SvelteKit

Svelte 4 and 5 plus SvelteKit — runes, stores, and what load runs where.

What this is

Svelte 4 and 5 plus SvelteKit — runes, stores, and what load runs where.

How to onboard it

A repository picks up this stack by onboarding on a profile that includes it. redline init detects the profile from what is in the repository, so in most cases this is automatic:

terminal
$ npx redlinegate init  # detects the profile
$ npx redlinegate init --profile web-svelte  # or name one

One profile pulls these rules in: web-svelte.

Once onboarded, your files match this stack when they fit any of these globs:

  • **/*.svelte
  • **/*.svelte.ts
  • **/*.svelte.js
  • src/routes/**
  • src/lib/**
  • apps/**/src/routes/**
  • apps/**/src/lib/**
  • packages/**/src/lib/**

How to use it — 28 rules

Nothing to run. Once your profile includes Svelte / SvelteKit, redline init renders these rules into your repository's AI tooling and the reviewer applies them on every pull request. When a review comment cites one of these ids, this table is where to look up what it catches and why.

Rule idSeverityCatches
svelte/html-tag-sinkBLOCKER`{@html}` with a value that was not sanitised server-side. Svelte does no escaping inside it. Markdown output, a CMS field and a translation string are all attacker-reachable often enough to count.
svelte/private-env-in-shared-codeBLOCKER`$env/static/private` or `$env/dynamic/private` imported from a component, a universal `+page.js`, or anything under `$lib` that a component imports. The build fails when it notices; when it does not notice, the secret ships to the browser.
svelte/secret-in-universal-loadBLOCKERA secret, a database handle or an internal-only URL used in `+page.js` / `+layout.js`. Universal load runs on the server *and* again in the browser. Server-only work belongs in `+page.server.js`.
svelte/module-context-shared-stateBLOCKERMutable state in `<script context="module">` (Svelte 5: `<script module>`), or at the top level of a `$lib` module. It is shared by every component instance, and on the server by every request — one user's data in another user's page.
svelte/endpoint-missing-authBLOCKERA form action or `+server.js` handler with no authorisation check. The route being reachable only from a guarded page is not a check; the endpoint is a public URL.
svelte/unvalidated-form-dataBLOCKER`await request.formData()` fields consumed without validation. A form action is an HTTP endpoint: field presence, type and length are all attacker-chosen.
svelte/async-store-startBLOCKERAn `async` start function for `readable`/`writable`, or an `async` `derived` callback. Svelte calls the start function's return value as the stop function, and a Promise is not callable, so the last unsubscribe throws `stop is not a function`; a one-argument async `derived` holds a Promise as its value. Do the async work in a synchronous callback and `set` on resolve.
svelte/browser-global-at-top-levelBLOCKER`window`, `document`, `localStorage` or `navigator` read at the top level of a component script in an SSR app. That code runs on the server too, so every direct page load is a 500 while client navigation still works. Move it into `onMount`/`$effect`, or guard with `browser` from `$app/environment`.
svelte/reactive-statement-side-effectHIGHA `$:` block performing a fetch, a mutation or navigation. Its dependencies are inferred from what it reads, so an unrelated assignment re-runs it — and a value it writes can re-trigger it.
svelte/store-not-unsubscribedHIGH`store.subscribe()` called manually without keeping and calling the returned unsubscriber. The `$store` auto-subscription form handles this; hand-rolled subscriptions in `onMount` usually do not.
svelte/effect-for-derived-stateHIGH`$effect` writing a `$state` that is computable from other state. `$derived` cannot go stale, cannot loop, and needs no cleanup.
svelte/effect-missing-cleanupHIGH`$effect` (or `onMount`) starting a timer, listener, observer or request with no teardown returned — it survives the component.
svelte/each-missing-keyHIGH`{#each}` over a list that reorders or filters, with no `(item.id)` key — Svelte reuses nodes positionally and component state follows position.
svelte/load-waterfallHIGHSequential `await`s in `load` for independent data. They serialise the whole page's time-to-first-byte; use `Promise.all`, or return the promises and stream them.
svelte/global-fetch-in-loadHIGH`load` using global `fetch` instead of the `fetch` from its event argument. The event's version forwards cookies, resolves relative URLs on the server and lets the SSR response be reused on hydration — the global one silently does none of that.
svelte/error-swallowed-in-loadHIGH`load` catching a failure and returning empty data instead of `error(status, …)`. The page renders as if there were no orders, rather than as an error.
svelte/state-mutation-across-boundaryHIGHMutating a `$state` object passed into a child, or a prop object, from that child. Ownership is the parent's; with `$props()` the write is not propagated back.
svelte/unvalidated-url-paramHIGH`params` / `url.searchParams` consumed as a typed value with no validation.
svelte/store-or-signal-as-valueHIGHA store used as an operand or rendered without `$` or `get()` — `{count}`, `if (user)`, `` `Hi ${name}` ``. The store object is always truthy and renders as `[object Object]`, so `{#if isLoggedIn}` always shows the admin menu. Use `$store` in components, `get(store)` in modules.
svelte/non-reactive-builtin-in-stateHIGHA `Map`, `Set`, `Date`, `URL` or `URLSearchParams` mutated in place, even inside `$state(...)`. `$state` proxies only plain objects and arrays, so `selected.add(id)` never updates the view. Use `SvelteMap`/`SvelteSet`/`SvelteDate` from `svelte/reactivity`, or replace the instance.
svelte/load-in-page-componentHIGH`load` (or Sapper's `preload`) exported from `+page.svelte` / `+layout.svelte`. SvelteKit only calls `load` from `+page(.server).js` and `+layout(.server).js`, so the page renders with `data` undefined and no error. Move it to the sibling file.
svelte/double-brace-mustacheHIGHVue/Angular-style `{{ value }}` in Svelte markup. Svelte reads it as an object literal, so the page shows `Hello [object Object]`. Use single braces: `{value}`.
svelte/handler-not-functionHIGHAn event handler bound to a call or a non-function (`onclick={save()}`). The expression runs on every render, so `onclick={deleteItem(item.id)}` deletes every item as the list renders, and the click does nothing. Bind `{save}` or `{() => save(id)}`. Not a factory that returns a handler.
svelte/bound-node-dom-mutationHIGH`.remove()`, `textContent =`, `innerHTML =`, `appendChild` on a node Svelte renders (`bind:this`). The runtime's view no longer matches the DOM, so the next update writes to a detached node or throws. Drive it through state and `{#if}`. Not an empty container handed to a third-party widget.
svelte/prefer-runesSUGGESTIONRunes (`$state`, `$derived`, `$props`) over `export let` and `$:` in new Svelte 5 components — the dependency graph stops being positional.
svelte/prefer-snippets-over-slotsSUGGESTIONSnippets over slots in new Svelte 5 code; they are typed and can take parameters.
svelte/prefer-derived-bySUGGESTION`$derived.by` for multi-statement derivations instead of an effect plus a `$state`.
svelte/oversized-componentSUGGESTIONComponent beyond ~300 lines — suggest extracting a child or a `.svelte.js` module.

Expected output

A finding from this file, and every finding Redline produces, opens with a machine-readable first line — severity, then the rule id in brackets, then the problem in one line:

a finding from this file
Redline/BLOCKER [svelte/html-tag-sink]: <one-line problem>

Ids are aggregated per rule, which is how the organisation finds out which rules earn their place and which only generate noise — so a finding without a valid id cannot be measured and counts as untagged. Of the 28 rules here, 8 BLOCKER, 16 HIGH and 4 SUGGESTION. Only a BLOCKER must not merge; a SUGGESTION may be dismissed without justification, and is never upgraded to get attention.

If a rule here fires constantly on code your team has deliberately decided to allow, that is the signal to raise with the standards owner — the rule id is what makes that conversation measurable — not to argue it away comment by comment.

How to edit it

Rules are edited in standards/stacks/svelte.md and nowhere else. The rendered copies in AGENTS.md, .github/copilot-instructions.md and .github/instructions/ are generated and are overwritten by the next render. A change here propagates to every onboarded repository as a pull request, so treat it as a production change.

  1. Edit the markdownstandards/ is the only place a human edits a rule. Everything under AGENTS.md, .github/copilot-instructions.md and .github/instructions/ is rendered from it and is overwritten by the next render.
  2. node scripts/assign-rule-ids.mjsAssigns a permanent <stack>/<slug> id to any new rule bullet and rewrites the file in place. Do not invent an id by hand. CI runs the same script with --check and fails if a rule is missing one.
  3. node scripts/render-self.mjsRe-renders this repository's own artifacts from the edited source. CI runs it with --check, so stale checked-in output fails the build.
  4. Bump standards/manifest.json → versionRequired in the same pull request as the rule change. Sync pull requests quote the version, so a repository's rendered artifacts always name where they came from.
  5. Add a CHANGELOG.md entryAlso in the same pull request. A standards change with no measurement is an opinion — record the seed score alongside it.
  6. node scripts/validate.mjsThe bundle self-check CI runs: manifest integrity, well-formed rule ids, the severity output contract surviving your edit, glob portability.

Rule ids are permanent. Rewording a rule is fine and keeps its id; renaming or removing an id orphans every historical telemetry record that cited it.

The full file

standards/stacks/svelte.md · 75 lines · 7.1 KB
# Svelte Review Rules

**Scope:** Svelte 4 and 5 components, stores, runes, and SvelteKit routes. Rules that
name a rune (`$state`, `$derived`, `$effect`) apply to Svelte 5 files; the store and
`$:` rules apply to Svelte 4 files and to Svelte 5 files still using them.

SvelteKit's load functions are the sharpest edge here: a file's name decides whether its
code runs only on the server or also in the browser, and nothing about the code says so.

## BLOCKER — request changes

- `svelte/html-tag-sink` — **`{@html}` with a value that was not sanitised server-side.** Svelte does no escaping inside it. Markdown output, a CMS field and a translation string are all attacker-reachable often enough to count.
- `svelte/private-env-in-shared-code` — **`$env/static/private` or `$env/dynamic/private` imported from a component, a universal `+page.js`, or anything under `$lib` that a component imports.** The build fails when it notices; when it does not notice, the secret ships to the browser.
- `svelte/secret-in-universal-load` — **A secret, a database handle or an internal-only URL used in `+page.js` / `+layout.js`.** Universal load runs on the server *and* again in the browser. Server-only work belongs in `+page.server.js`.

  ```js
  // WRONG — +page.js, also runs in the browser
  export const load = async () => db.query('select * from orders');
  // RIGHT — +page.server.js
  export const load = async () => ({ orders: await db.query('select * from orders') });
  ```

- `svelte/module-context-shared-state` — **Mutable state in `<script context="module">` (Svelte 5: `<script module>`), or at the top level of a `$lib` module.** It is shared by every component instance, and on the server by every request — one user's data in another user's page.
- `svelte/endpoint-missing-auth` — **A form action or `+server.js` handler with no authorisation check.** The route being reachable only from a guarded page is not a check; the endpoint is a public URL.
- `svelte/unvalidated-form-data` — **`await request.formData()` fields consumed without validation.** A form action is an HTTP endpoint: field presence, type and length are all attacker-chosen.
- `svelte/async-store-start` — **An `async` start function for `readable`/`writable`, or an `async` `derived`
  callback.** Svelte calls the start function's return value as the stop function, and a Promise is not
  callable, so the last unsubscribe throws `stop is not a function`; a one-argument async `derived` holds a
  Promise as its value. Do the async work in a synchronous callback and `set` on resolve.
- `svelte/browser-global-at-top-level` — **`window`, `document`, `localStorage` or `navigator` read at the top
  level of a component script in an SSR app.** That code runs on the server too, so every direct page load is a
  500 while client navigation still works. Move it into `onMount`/`$effect`, or guard with `browser` from
  `$app/environment`.

## HIGH

- `svelte/reactive-statement-side-effect` — A `$:` block performing a fetch, a mutation or navigation. Its dependencies are inferred from what it reads, so an unrelated assignment re-runs it — and a value it writes can re-trigger it.
- `svelte/store-not-unsubscribed` — `store.subscribe()` called manually without keeping and calling the returned unsubscriber. The `$store` auto-subscription form handles this; hand-rolled subscriptions in `onMount` usually do not.
- `svelte/effect-for-derived-state` — `$effect` writing a `$state` that is computable from other state. `$derived` cannot go stale, cannot loop, and needs no cleanup.
- `svelte/effect-missing-cleanup` — `$effect` (or `onMount`) starting a timer, listener, observer or request with no teardown returned — it survives the component.
- `svelte/each-missing-key` — `{#each}` over a list that reorders or filters, with no `(item.id)` key — Svelte reuses nodes positionally and component state follows position.
- `svelte/load-waterfall` — Sequential `await`s in `load` for independent data. They serialise the whole page's time-to-first-byte; use `Promise.all`, or return the promises and stream them.
- `svelte/global-fetch-in-load` — `load` using global `fetch` instead of the `fetch` from its event argument. The event's version forwards cookies, resolves relative URLs on the server and lets the SSR response be reused on hydration — the global one silently does none of that.
- `svelte/error-swallowed-in-load` — `load` catching a failure and returning empty data instead of `error(status, …)`. The page renders as if there were no orders, rather than as an error.
- `svelte/state-mutation-across-boundary` — Mutating a `$state` object passed into a child, or a prop object, from that child. Ownership is the parent's; with `$props()` the write is not propagated back.
- `svelte/unvalidated-url-param` — `params` / `url.searchParams` consumed as a typed value with no validation.
- `svelte/store-or-signal-as-value` — **A store used as an operand or rendered without `$` or `get()`** —
  `{count}`, `if (user)`, `` `Hi ${name}` ``. The store object is always truthy and renders as `[object
  Object]`, so `{#if isLoggedIn}` always shows the admin menu. Use `$store` in components, `get(store)` in
  modules.
- `svelte/non-reactive-builtin-in-state` — **A `Map`, `Set`, `Date`, `URL` or `URLSearchParams` mutated in
  place, even inside `$state(...)`.** `$state` proxies only plain objects and arrays, so `selected.add(id)`
  never updates the view. Use `SvelteMap`/`SvelteSet`/`SvelteDate` from `svelte/reactivity`, or replace the
  instance.
- `svelte/load-in-page-component` — **`load` (or Sapper's `preload`) exported from `+page.svelte` /
  `+layout.svelte`.** SvelteKit only calls `load` from `+page(.server).js` and `+layout(.server).js`, so the
  page renders with `data` undefined and no error. Move it to the sibling file.
- `svelte/double-brace-mustache` — **Vue/Angular-style `{{ value }}` in Svelte markup.** Svelte reads it as an
  object literal, so the page shows `Hello [object Object]`. Use single braces: `{value}`.
- `svelte/handler-not-function` — **An event handler bound to a call or a non-function** (`onclick={save()}`).
  The expression runs on every render, so `onclick={deleteItem(item.id)}` deletes every item as the list
  renders, and the click does nothing. Bind `{save}` or `{() => save(id)}`. Not a factory that returns a
  handler.
- `svelte/bound-node-dom-mutation` — **`.remove()`, `textContent =`, `innerHTML =`, `appendChild` on a node
  Svelte renders (`bind:this`).** The runtime's view no longer matches the DOM, so the next update writes to a
  detached node or throws. Drive it through state and `{#if}`. Not an empty container handed to a third-party
  widget.

## SUGGESTION

- `svelte/prefer-runes` — Runes (`$state`, `$derived`, `$props`) over `export let` and `$:` in new Svelte 5 components — the dependency graph stops being positional.
- `svelte/prefer-snippets-over-slots` — Snippets over slots in new Svelte 5 code; they are typed and can take parameters.
- `svelte/prefer-derived-by` — `$derived.by` for multi-statement derivations instead of an effect plus a `$state`.
- `svelte/oversized-component` — Component beyond ~300 lines — suggest extracting a child or a `.svelte.js` module.