I assumed SvelteKit 5 would be a drop-in upgrade. Update the package.json, run npm install, maybe fix a few type errors. That is how it worked with Svelte 3 to 4. This time, the code compiled fine on the first try — but my brain did not.
The migration took three days longer than expected, not because of broken builds, but because I kept trying to think in Svelte 4 patterns while the framework wanted me to think in runes. Here is what actually caught me off guard.
The Migration Script Lied (Kind Of)
Running npx sv migrate svelte-5 worked surprisingly well. It converted my let declarations to $state(), my $: statements to $derived() and $effect(). The diff was thousands of lines, mostly mechanical changes. I committed everything and ran the dev server expecting green checkmarks.
But here is the part that tripped me up: the migration script cannot migrate your understanding. It turned this:
let count = 0;
$: doubled = count * 2;Into this:
let count = $state(0);
const doubled = $derived(count * 2);What I did not immediately grasp was that $derived runs differently than $:. In Svelte 4, that reactive statement ran once before render and only updated when its dependencies changed. In Svelte 5, $derived is lazy — it only runs when you read the value, and it is memoized. Which sounds better, until you are debugging and wondering why your console.log is not firing when you expect it to.
I Kept Trying to Export Let
The second day was spent fighting with component props. The migration script converted export let propName to $props(), but I kept writing new components the old way out of habit. Muscle memory is real — I would type export let automatically, then stare at the error for thirty seconds before remembering.
The new $props() syntax with destructuring felt verbose at first:
// Old way (Svelte 4)
export let title = 'Default';
export let required;
// New way (Svelte 5)
let { title = 'Default', required } = $props();But I have to admit: renaming props is cleaner now. I used to hate writing export { className as class } or dealing with $$restProps. Now it is just let { class: className, ...rest } = $props() and everything behaves predictably. The migration guide was not lying when they said this reduces API surface area.
Slots to Snippets Broke My Brain
The biggest friction was not syntax — it was conceptual. Svelte 4 slots felt like HTML. You had a <slot />, you passed content between tags, it just worked. Svelte 5 snippets are more powerful but require a mental shift.
In Svelte 4, I would write:
<!-- Card.svelte -->
<div class="card">
<slot name="header" />
<slot />
</div>In Svelte 5, that becomes:
<!-- Card.svelte -->
<script>
let { header, children } = $props();
</script>
<div class="card">
{@render header?.()}
{@render children?.()}
</div>The ?.() optional chaining matters — if you do not include it and the parent does not provide that snippet, you get a runtime error. I learned this the hard way with a custom modal component that crashed when I tried to render it without a header.
The $app/stores Deprecation Sneak Attack
SvelteKit 2.12 deprecated $app/stores in favor of $app/state. I did not see this coming because I was focused on the Svelte 5 migration, not SvelteKit changes. Suddenly my $page.params access felt wrong.
// Old way (deprecated)
import { page } from '$app/stores';
$page.params.slug;
// New way
import { page } from '$app/state';
page.params.slug; // No $ prefix needed!The fine-grained reactivity is actually better — updating page.state no longer invalidates page.data — but finding all the $page references across a large codebase took longer than I expected. The npx sv migrate app-state command helped, but it only catches .svelte files, not the .svelte.ts utility functions I had started writing.
The Dependency Version Trap
Before you even touch the migration script, check your package.json. SvelteKit 2 requires specific minimum versions across the entire ecosystem, and the migration script does not always handle peer dependencies correctly.
Here is what you actually need:
{
"devDependencies": {
"svelte": "^5.55.0",
"@sveltejs/kit": "^2.56.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"vite": "^7.0.0",
"typescript": "^5.0.0"
}
}The gotcha: @sveltejs/vite-plugin-svelte 5.x+ is now a peerDependency, not a direct dependency of SvelteKit. If you just bump SvelteKit and forget that one, you will get cryptic errors about missing preprocessors or failed TypeScript compilation. The migration script will update it, but double-check — I have seen it miss this in projects with complex dependency trees.
I also had to bump the adapter — Cloudflare adapter 2.x does not work with SvelteKit 2.5x+. You need @sveltejs/adapter-cloudflare version 5.x minimum. Same story for the other adapters: Netlify needs 5.x, Node needs 5.x, Vercel needs 5.x. The adapter versioning now roughly tracks SvelteKit minor versions, which is easier to remember but means you cannot skip adapter updates anymore.
Node version matters too. SvelteKit 2 requires Node 18.13 or higher. If you are still on Node 16 in production — and I was, on an older VPS — the build will fail with errors that look like import path problems but are actually just "your Node is too old."
The migration script updates your package.json, but it does not always pick the right versions if you have conflicting constraints. I had to manually delete node_modules and lockfile twice before everything resolved correctly.
What Actually Annoyed Me
Here is my genuine complaint: the documentation presents Svelte 5 as "almost completely backwards-compatible," which is technically true at the compiler level but misleading at the human level. Yes, your Svelte 4 components work in a Svelte 5 app. But as soon as you start mixing patterns — using a Svelte 5 snippet inside a Svelte 4 slot-based component, for example — things get weird.
I spent two hours debugging why a snippet was not rendering before realizing the parent component was still using <slot /> instead of {@render children?.()}. The reverse does not work: you cannot pass slotted content to a component that expects snippets.
Also, the migration script leaves createEventDispatcher calls untouched because it is "too risky." So now I have a codebase that is half callback props, half event dispatchers, and I have to remember which components use which pattern. It is not unmanageable, but it is not the seamless upgrade the blog posts promised either.
What I Think Now
After a week of working with it, I am warming up to runes. Being able to pull reactive logic out of components into .svelte.ts files without stores is genuinely useful. I refactored a complex form validation system that was previously using four different stores into a single reusable function with $state and $derived, and it is easier to test now.
But I would recommend a different migration strategy than the one I used. Do not run the migration script on your entire codebase at once. Start with leaf components — the simple presentational ones with no slots or events. Get comfortable with the new syntax there first. Then tackle the complex components with slots and callbacks. Save the global changes (like $app/stores to $app/state) for last.
I am not 100% sure if Svelte 5 is actually faster for my use case. The bundle size dropped slightly — about 3KB gzipped — but that is in the noise for most apps. The real win, if there is one, will be in maintainability six months from now when I am not debugging mysterious $: statement ordering issues.
