Skip to content

Svelte 5 migration

Why Svelte 5

Svelte 5 introduces runes — a new reactivity system that replaces Svelte 4's implicit reactivity with explicit declarations.

Svelte 4 Svelte 5
let count = 0 was implicitly reactive only at component top-level let count = $state(0) is explicitly reactive anywhere
Reactivity couldn't be refactored into external files Runes work in .svelte.ts files, enabling shared reactive logic
$: reactive statements mixed derivations and side effects $derived and $effect separate concerns
Slots for component composition Snippets provide more flexible composition

The migration also updated the router from svelte-routing to @mateothegreat/svelte5-router, which is designed for Svelte 5's component model.

Runes overview

Runes are compiler directives that look like function calls but are processed at compile time. They're available in .svelte files and .svelte.ts files without imports.

$state

Creates reactive state that triggers UI updates when modified:

<script lang="ts">
  let count = $state(0);                         // Reactive primitive
  let user = $state({ name: '', email: '' });    // Reactive object (deeply reactive)
  let items = $state<Item[]>([]);                // Reactive array (.push() works)

  function increment() {
    count += 1;  // Triggers update
  }
</script>

<button onclick={increment}>{count}</button>

Use $state for variables displayed in the template that can change, variables controlling conditional rendering, arrays and objects that will be mutated, and any state that should trigger re-renders. Don't use $state for DOM element references (bind:this), cleanup functions and subscriptions, constants that never change, or helper variables used only inside functions:

<script lang="ts">
  let loading = $state(false);           // Needs $state - displayed and updated
  let items = $state<Item[]>([]);        // Needs $state - displayed and mutated

  let containerEl: HTMLElement;          // No $state - DOM reference
  let unsubscribe: (() => void) | null;  // No $state - cleanup function
  const API_ENDPOINT = '/api/v1';        // No $state - constant
</script>

$derived

Creates computed values that automatically update when dependencies change:

<script lang="ts">
  let items = $state<Item[]>([]);
  let filter = $state('');

  let count = $derived(items.length);    // Simple derivation

  // Complex derivation with $derived.by
  let filteredItems = $derived.by(() => {
    if (!filter) return items;
    return items.filter(item =>
      item.name.toLowerCase().includes(filter.toLowerCase())
    );
  });
</script>

Use $derived for any value computed from reactive state. Use $derived.by when the computation needs multiple statements.

$effect

Runs side effects when dependencies change:

<script lang="ts">
  let theme = $state('dark');

  $effect(() => {
    document.documentElement.classList.toggle('dark', theme === 'dark');
    return () => console.log('Cleaning up previous effect');  // Optional cleanup
  });
</script>

Use $effect sparingly — most reactive needs are better served by $derived. Reserve effects for DOM manipulation outside Svelte's control, external library integration, subscriptions and event listeners, and logging/debugging.

$props

Declares component props with destructuring:

<script lang="ts">
  // Svelte 4: export let size = 'medium';
  // Svelte 5:
  let { size = 'medium', disabled = false } = $props();

  // With TypeScript interface
  interface Props {
    size?: 'small' | 'medium' | 'large';
    disabled?: boolean;
    children?: Snippet;
  }
  let { size = 'medium', disabled = false, children } = $props<Props>();
</script>

Event handlers

Svelte 5 uses standard DOM event attributes instead of the on: directive:

<!-- Svelte 4 -->
<button on:click={handleClick}>Click</button>
<form on:submit|preventDefault={handleSubmit}>

<!-- Svelte 5 -->
<button onclick={handleClick}>Click</button>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
Svelte 4 Svelte 5
on:click\|preventDefault onclick={(e) => { e.preventDefault(); handler(); }}
on:click\|stopPropagation onclick={(e) => { e.stopPropagation(); handler(); }}
on:keydown\|self onkeydown={(e) => { if (e.target === e.currentTarget) handler(); }}

All standard DOM events follow this pattern: on:eventname becomes oneventname.

Snippets

Snippets replace slots for component composition:

<!-- Svelte 4: Parent -->
<Card>
  <h2 slot="header">Title</h2>
  <p>Content goes here</p>
</Card>

<!-- Svelte 4: Card.svelte -->
<div class="card">
  <slot name="header" />
  <slot />
</div>
<!-- Svelte 5: Parent -->
<Card>
  {#snippet header()}
    <h2>Title</h2>
  {/snippet}
  <p>Content goes here</p>
</Card>

<!-- Svelte 5: Card.svelte -->
<script lang="ts">
  import type { Snippet } from 'svelte';

  interface Props {
    header?: Snippet;
    children?: Snippet;
  }
  let { header, children } = $props<Props>();
</script>

<div class="card">
  {#if header}{@render header()}{/if}
  {@render children?.()}
</div>

For simple cases with just a default slot, declare children as a Snippet prop and render with {@render children?.()}.

Component instantiation

The new Component() syntax is replaced with mount():

// Svelte 4
const app = new App({ target: document.body });

// Svelte 5
import { mount } from 'svelte';
const app = mount(App, { target: document.body });

Router migration

The frontend uses @mateothegreat/svelte5-router instead of svelte-routing. The main API change is that navigate('/path') becomes goto('/path'), and <Link to="/path"> becomes <a href="/path" use:route>. The <Route> component syntax remains the same.

svelte-routing svelte5-router
navigate('/path') goto('/path')
<Link to="/path"> <a href="/path" use:route>
<Route path="/" component={Home} /> <Route path="/" component={Home} />

Routes are configured in App.svelte by importing Router and Route from @mateothegreat/svelte5-router, then wrapping route definitions. Protected routes wrap their children in ProtectedRoute. For programmatic navigation, import goto from the router and call it after state changes like logout. For links, use the route action on anchor elements: <a href="/settings" use:route>.

Stores compatibility

Svelte stores (writable, derived, readable) work unchanged in Svelte 5. The $store auto-subscription syntax continues to work:

<script lang="ts">
  import { theme } from '../stores/theme';
  import { isAuthenticated, username } from '../stores/auth';
</script>

{#if $isAuthenticated}
  <span>Welcome, {$username}</span>
{/if}

<button onclick={() => theme.set('dark')}>Current: {$theme}</button>

Use stores for shared state across components, persisted state, and complex async state. Use runes ($state) for component-local state and simple reactive values. The existing stores (auth.ts, theme.ts, toastStore.ts) remain as stores because they manage global, shared state.

Build configuration

The Svelte plugin in rollup.config.js requires the runes: true compiler option to enable Svelte 5's runes mode:

svelte({
    preprocess: sveltePreprocess({ postcss: true }),
    compilerOptions: {
        dev: !production,
        runes: true,
    },
}),

The key dependencies are svelte at ^5.46.0, @mateothegreat/svelte5-router at ^2.16.19, and svelte-preprocess at ^6.0.3.

Migration patterns

The most common migration is converting reactive variables. A Svelte 4 variable let count = 0 with a reactive statement $: doubled = count * 2 becomes let count = $state(0) with let doubled = $derived(count * 2).

For reactive statements with side effects, replace $: if (query) { fetchResults(query); } with an $effect block that runs the same logic. The effect automatically tracks query as a dependency.

Form handling changes in two ways: state variables get $state, and the form element uses onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} instead of on:submit|preventDefault={handleSubmit}.

Component props migrate from export let size = 'medium' to destructuring with $props(). If you need computed values from props, add a $derived declaration.

For cleanup patterns, both approaches work: the traditional onMount/onDestroy with an unsubscribe function, or the newer $effect with a cleanup return function. Choose whichever reads more clearly for your use case.

Common pitfalls

Destructuring reactive objects breaks reactivity. If you have let user = $state({ name: 'Alice' }), don't destructure with let { name } = user — access properties directly as user.name in the template.

Variables that are reassigned and displayed in the template need $state. A plain let count = 0 won't update the UI when incremented; it needs to be let count = $state(0).

Avoid using $effect for derivations. If you find yourself writing $effect(() => { total = items.reduce(...) }), refactor to let total = $derived(items.reduce(...)). Effects are for side effects, not computed values.

HTML elements like <textarea>, <div>, and <span> cannot be self-closing in Svelte 5. Use <textarea></textarea> instead of <textarea />.

File-by-file changes

File Changes
main.ts new App()mount(App, { target })
App.svelte Router imports, $derived for theme
ProtectedRoute.svelte $props, snippet children, goto()
AdminLayout.svelte $props, snippet children
File State variables
Header.svelte isMenuActive, isMobile, showUserDropdown
Editor.svelte executing, result, showLimits, showOptions, etc.
ToastContainer.svelte toastList
NotificationCenter.svelte showDropdown, loading, notifications
Spinner.svelte None (uses $derived for sizeClass)

All components using on:click, on:submit, on:change, etc. were updated to onclick, onsubmit, onchange.

Verification

After migration, verify the build succeeds with npm run build. Expected output shows only Svelte internal circular dependency notes (normal for Svelte 5).

Test that all routes navigate correctly, authentication works, protected routes redirect properly, theme switching works, notifications display, toast messages appear, the editor loads and executes code, and admin pages function correctly.