Skip to content

Project & Tooling

The current way to start a Svelte or SvelteKit project is the sv CLI. One command scaffolds the project and can add integrations (TypeScript, Prettier, ESLint, Tailwind, testing) interactively:

Terminal window
npx sv create my-app
cd my-app
npm install
npm run dev

sv create replaced the older npm create svelte@latest flow. It sets up a SvelteKit project by default (the recommended way to build Svelte apps), wires up Vite, and lets you opt into add-ons up front. There’s also npx sv add <name> to add an integration to an existing project later.

A Svelte project runs on Vite. In development, Vite serves your modules over native ESM with hot module replacement — edit a component and the change appears without a full reload, preserving state where possible. For production, vite build bundles and optimizes the output.

{
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
}
}

The Svelte integration for Vite (@sveltejs/vite-plugin-svelte, wired up for you by sv create) is what runs the compiler on your .svelte files during dev and build. You rarely touch it directly.

flowchart LR
  code["your .svelte / .ts files"] --> vite["Vite + svelte plugin"]
  vite --> dev["dev: HMR server"]
  vite --> build["build: compiled, bundled output"]
The dev and build pipeline

Two tools keep the authoring experience honest:

  • The Svelte editor extension (official, for VS Code and others) gives syntax highlighting, autocompletion, and inline diagnostics for .svelte files — including type errors inside the markup, not just the script.
  • svelte-check is the command-line equivalent: it type-checks and lints your whole project, including expressions in templates, so you catch errors in CI that the editor would show you locally.
Terminal window
npm run check # runs svelte-check across the project

Because svelte-check understands .svelte files end to end, it catches mistakes a plain tsc would miss — a wrong prop type passed in markup, a typo in a template expression, an unused CSS selector.

“Catches a wrong prop type passed in markup” isn’t a hypothetical — here’s a component with a typed prop, and a caller that passes the wrong type:

src/lib/Greeter.svelte
<script lang="ts">
interface Props {
name: string;
}
let { name }: Props = $props();
</script>
<p>Hello {name.toUpperCase()}</p>
src/lib/BadUsage.svelte
<!-- @expect-error Type 'number' is not assignable to type 'string'. -->
<script lang="ts">
import Greeter from './Greeter.svelte';
</script>
<Greeter name={42} />

Running svelte-check against the project (real output, only the path trimmed):

$ svelte-check --tsconfig ./tsconfig.json
src/lib/BadUsage.svelte:4:10
Type 'number' is not assignable to type 'string'.

A plain tsc run over this project wouldn’t catch this at all — the type error is inside a .svelte file’s markup (<Greeter name={42} />), which only a tool that understands the .svelte format, not just .ts/.tsx, can type-check.

Linting .svelte files: eslint-plugin-svelte

Section titled “Linting .svelte files: eslint-plugin-svelte”

Type-checking is one axis; linting is another. sv create can wire up ESLint with eslint-plugin-svelte, which understands Svelte-specific patterns a generic JS/TS linter has no concept of — an unescaped {@html}, a missing {#each} key, a deprecated on: directive. It’s ESLint’s modern flat config (eslint.config.js) only, matching current ESLint itself:

eslint.config.js
import svelteConfig from './svelte.config.js';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
export default defineConfig([
js.configs.recommended,
svelte.configs.recommended,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
},
{
files: ['**/*.svelte', '**/*.svelte.js', '**/*.svelte.ts'],
languageOptions: { parserOptions: { svelteConfig } },
},
]);

Run against a component that injects unsanitized HTML:

src/lib/LintViolation.svelte
<script>
let unsafeHtml = $state('<b>hi</b>');
let count = $state(0);
</script>
{@html unsafeHtml}
<button onclick={() => count++}>{count}</button>

Real output (eslint-plugin-svelte 3.23.0, ESLint 10):

$ npx eslint src/lib/LintViolation.svelte
/.../src/lib/LintViolation.svelte
6:1 error `{@html}` can lead to XSS attack svelte/no-at-html-tags
✖ 1 problem (1 error, 0 warnings)

svelte.configs.recommended bundles rules like this one — plus deprecation-aware rules that flag on:click and other Svelte-4-era patterns — so a CI lint step catches them before a PR merges, not just when svelte-check happens to notice.

vite-plugin-svelte: the compiler options underneath everything

Section titled “vite-plugin-svelte: the compiler options underneath everything”

@sveltejs/vite-plugin-svelte (wired up by sv create, mentioned earlier in this lesson) is where every compiler option ultimately gets set for the whole project, in svelte.config.js:

svelte.config.js
export default {
compilerOptions: {
// e.g. { runes: true } to force runes mode project-wide (see the
// "Why Svelte, the Compiler" lesson for what this overrides)
},
vitePlugin: {
// dynamicCompileOptions lets you vary compiler options PER FILE —
// e.g. forcing runes mode only for files under one directory during
// an incremental migration.
experimental: {
dynamicCompileOptions({ filename, compileOptions }) {
if (filename.includes('/src/routes/legacy/') || compileOptions.runes) return;
return { runes: true };
},
},
},
};

The experimental.async flag this course’s Async Svelte lesson proves — required before await works inside $derived or a template expression — is set exactly this way, as a compilerOptions.experimental entry; that lesson’s real compile error (Cannot use \await` in deriveds and template expressions… unless the `experimental.async` compiler option is `true“) is what happens when this project-wide switch is left off.

Migrating Svelte 4 code: sv migrate svelte-5

Section titled “Migrating Svelte 4 code: sv migrate svelte-5”

For an existing Svelte 4 project, the sv CLI also drives the migration itself:

Terminal window
npx sv migrate svelte-5

Per the migration guide: it “automates repetitive tasks such as updating dependencies, converting let declarations to runes, migrating event attributes like on:click to onclick, and transforming slot-based patterns into the new snippet and render tag syntax.” It handles the mechanical rewriting; the legacy-recognition table in the events & attributes lesson is the reference for what each of those transformations targets, and for the real compile errors you’ll hit on anything the script’s automated pass doesn’t fully cover.

Everything in this lesson is tooling built around one fact: a .svelte file is not plain JavaScript, so any tool that only understands JS/TS (tsc, a generic ESLint config) is structurally blind to markup-level mistakes. svelte-check and eslint-plugin-svelte both exist because someone has to parse the .svelte format itself before type-checking or linting can see inside the markup — that’s the whole reason they’re separate tools instead of tsc/eslint just working out of the box.

What you getBenefitCost
svelte-check over plain tscCatches errors inside markup — proven above with a prop-type mismatch tsc alone cannot seeA second, slower command in CI; needs its own --tsconfig invocation
eslint-plugin-svelte flat configSvelte-aware rules ({@html} risk, deprecated on:, missing each-keys) — proven aboveFlat config only; a project still on .eslintrc.js must migrate that first
dynamicCompileOptions for per-file compiler settingsLets a big codebase force runes mode file-by-file during migration, instead of all-or-nothingOne more layer of project config to reason about beyond the simple compilerOptions object
sv migrate svelte-5Automates the mechanical parts of a Svelte 4 → 5 upgradeDoes not catch every case — anything it misses surfaces as the real compile errors proven in the legacy-recognition table
  • “tsc alone is enough to type-check a Svelte project.” Proven above: a wrong prop type passed in a .svelte file’s markup is invisible to plain tsc — only svelte-check, which actually parses the Svelte file format, catches it.
  • “Any ESLint config works with .svelte files.” eslint-plugin-svelte v3 requires flat config (eslint.config.js) specifically, and needs svelte-eslint-parser to even read the file — a legacy .eslintrc.js setup doesn’t lint .svelte files’ markup at all.
  • “experimental.async is a <svelte:options> or per-component setting.” It’s a project-wide (or, via dynamicCompileOptions, per-file) compiler option set through vite-plugin-svelte/svelte.config.js — not something you toggle per component.

When reviewing project configuration and tooling setup, check for:

  • Is CI running only tsc --noEmit (or nothing at all) for a SvelteKit project, with no svelte-check step — meaning markup-level type errors like the one proven above ship silently?
  • Is there an .eslintrc.js/.eslintrc.json alongside eslint-plugin-svelte — a combination that doesn’t work, since the plugin’s current major version requires flat config only?
  • Does a lesson, README, or generated snippet still say npm create svelte@latest or reference svelte-migrate as a standalone package, instead of the current npx sv create / npx sv migrate svelte-5?
  • Is a compiler option like experimental.async set somewhere per-component (a prop, a comment) rather than in svelte.config.js’s compilerOptions/vitePlugin, where it actually has to live?

🐛 Spot the bug

eslint.config.js
import js from '@eslint/js';
export default [
js.configs.recommended,
// ... rest of the config, no Svelte-specific plugin at all
];
Show the answer

This lints plain .js/.ts files fine, but it never mentions eslint-plugin-svelte or its parser — so every .svelte file in the project is either skipped entirely or, if included via a glob, parsed as if it were plain JavaScript (which it isn’t: the markup, {expressions}, and directives aren’t valid JS syntax). Either way, none of the Svelte-specific rules proven above (like the real svelte/no-at-html-tags error) ever run, and a review that only looks at whether “ESLint passes” gets false confidence.

The fix is the flat config from this lesson: import eslint-plugin-svelte, extend svelte.configs.recommended, and point .svelte/.svelte.js/.svelte.ts files at the Svelte parser via languageOptions.parserOptions.svelteConfig.

What is the current command to scaffold a new Svelte/SvelteKit project?
What does Vite provide during development?
Why use svelte-check instead of plain tsc?
Which real tool flags this specific line as a risk, and with what rule name?
{@html unsafeHtml}
What does eslint-plugin-svelte (current major version) require to lint .svelte files at all?
Where does a project-wide compiler option like experimental.async actually get set?
What does npx sv migrate svelte-5 do, per the official migration guide?
What does svelte-check report for this, and why does plain tsc miss it?
<!-- Greeter.svelte declares: interface Props { name: string } -->
<Greeter name={42} />