Svelte 5 is here, and the upgrade path is smoother than you'd expect
Svelte 5 brings a fundamentally different reactivity model with runes, and if you're maintaining a production Svelte 4 codebase, you're probably wondering how to get there without breaking everything. Migrating to Svelte 5 for Svelte & SvelteKit developers doesn't have to mean a complete rewrite—the framework gives us tooling and a hybrid approach that lets old and new patterns coexist. We've run this upgrade on several production applications, and the key is understanding what changed, using the migration script wisely, and planning your approach section by section.
The migration script: Your first tool
Svelte provides an official migration script that handles a lot of the heavy lifting. Run it early, not as a magic fix, but as a starting point:
npx sv migrate svelte-5This script walks through your codebase and updates syntax automatically. It converts reactive declarations, handles store subscriptions, and updates import paths. In a typical mid-size SvelteKit app with 30–50 components, we've seen it handle about 70 percent of the work.
What the script does well:
- Converts
$:reactive declarations to rune-based equivalents - Updates
export letto use$props()where appropriate - Transforms store subscriptions from
$storesyntax to explicit.subscribe()or store runes - Adjusts event handlers and lifecycle hooks
- Updates imports from
svelteandsvelte/store
However, the script isn't perfect. Complex component logic, custom stores, and edge cases require manual review. After running it, we always do a full test pass on affected routes and components. The script gives you correct syntax, but you need to verify behavior still matches expectations.
Pro tip: Commit your code before running the script, then review the diff carefully. You'll spot patterns and can batch-fix similar issues manually if needed.
Runes mode: Opting in to new reactivity
Svelte 5 introduces runes—a new reactivity primitive. Instead of reactive declarations and magic, you explicitly mark reactive state with $state(), $derived(), and $effect(). Components don't automatically switch to runes mode; you opt in by using a rune anywhere in the component.
Here's what a Svelte 4 counter looks like:
<script>
let count = 0;
$: doubled = count * 2;
function increment() {
count++;
}
</script>
<button on:click={increment}>
Count: {count} (Doubled: {doubled})
</button>In Svelte 5 with runes, that becomes:
<script>
let count = $state(0);
let doubled = $derived(count * 2);
function increment() {
count++;
}
</script>
<button onclick={increment}>
Count: {count} (Doubled: {doubled})
</button>The differences are subtle but important. $state() marks a variable as reactive. $derived() creates a computed value that updates when dependencies change. $effect() runs side effects when dependencies change—replacing lifecycle hooks and watchers.
Consider a form component that syncs with an API:
<script>
import { onMount } from 'svelte';
let user = $state({ name: '', email: '' });
let loading = $state(false);
let error = $state(null);
async function loadUser(userId) {
loading = true;
error = null;
try {
const response = await fetch(`/api/users/${userId}`);
user = await response.json();
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
$effect(() => {
// This runs whenever user data might change
console.log('User updated:', user);
});
onMount(() => {
loadUser(1);
});
</script>
<form>
{#if loading}
<p>Loading...</p>
{/if}
{#if error}
<p>Error: {error}</p>
{/if}
<input bind:value={user.name} />
<input bind:value={user.email} />
</form>Opting into runes doesn't break your entire app. You can migrate components one at a time. A component that doesn't use any runes continues to work with Svelte 4 semantics inside Svelte 5.
Events and slots: From on: directives to callback props and snippets
Svelte 5 shifts how components communicate. Custom events still exist, but the framework nudges you toward explicit callback props. Slots become snippets—reusable blocks of markup you pass into components.
In Svelte 4, a modal component typically used event dispatching:
<script>
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
function handleClose() {
dispatch('close');
}
</script>
<div class="modal">
<button on:click={handleClose}>Close</button>
<slot />
</div>In Svelte 5, you'd use a callback prop:
<script>
let { onClose } = $props();
</script>
<div class="modal">
<button onclick={onClose}>Close</button>
{@render children()}
</div>Snippets let you pass template code into components cleanly. Instead of named slots, you pass a snippet:
<script>
let { children } = $props();
</script>
<div class="card">
{@render children()}
</div>And when using it:
<Card>
{#snippet children()}
<h2>My card content</h2>
{/snippet}
</Card>This is more explicit and type-safe. Your IDE can track what's passed in and what's expected. For teams, this clarity reduces bugs.
Mixed mode: Old and new can coexist
You don't need to migrate everything at once. Svelte 5 runs a compatibility layer for Svelte 4 components. A form component using old reactive declarations can sit next to a modal component using runes.
This matters in production. You can ship Svelte 5 and migrate incrementally:
- Run the migration script on your entire codebase
- Test thoroughly, fix what broke
- Pick high-value or low-risk components to refactor with runes
- Leave stable, working components alone for now
- Over time, rune-ify as you touch components
A common strategy: migrate layout components and stores first. These tend to be stable and affect many child components. Then move to page-level components, then leaf components. This order gives you the most leverage and confidence.
In one SvelteKit project, we migrated the main layout and store layer in week one, then spent the next two weeks on pages and modals. By week four, about 60 percent of the codebase was using runes, and we'd caught most edge cases.
Common mistakes
Forgetting to handle event names changing. Svelte 5 changes on:click to onclick in some cases. The migration script catches most, but custom event handlers need manual review.
Not testing store behavior. Store subscriptions work differently. If you're using Svelte stores with derived and writable stores, make sure the logic still works. We've seen edge cases where unsubscribe timing changed.
Mixing metaphors in a single component. A component using both runes and Svelte 4 patterns will compile, but it's confusing. Consistency within a component helps readability and debugging.
Underestimating prop behavior changes. In Svelte 5, props are immutable by default. If you were mutating props in Svelte 4, that pattern breaks. Refactor to update state in the parent.
Skipping the test pass. The migration script is good, but automated migration can't catch logic changes. Components might compile but behave differently. Integration tests and manual QA are essential.
Key takeaways
- Use
npx sv migrate svelte-5as a starting point, then review and test thoroughly—it handles syntax but not all semantics. - Opt into runes incrementally; components without runes continue working with Svelte 4 reactivity inside Svelte 5.
- Shift from event dispatchers and slots to callback props and snippets for clearer, more type-safe component APIs.
- Migrate high-leverage components (layouts, stores) first, then spread to pages and leaves over time.
- Run comprehensive tests after migration; automated syntax changes don't guarantee behavioral equivalence.
- Mixed-mode codebases are stable and expected—there's no hard deadline to rune-ify everything at once.






Comments
Loading comments…