I Assumed SvelteKit 5 Would Just Work. I Was Wrong About the Mental Model.

I assumed SvelteKit 5 would be a drop-in upgrade. Update the package.json, run npm install, maybe fix a few type errors. That's how it worked with Svelte 3 to 4. This time, the code compiled fine on the first try — but my brain didn't.

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's 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's the part that tripped me up: the migration script can't migrate your understanding. It turned this:

let count = 0;
$: doubled = count * 2;let count = 0;
$: doubled = count * 2;

Into this:

let count = $state(0);
const doubled = $derived(count * 2);let count = $state(0);
const doubled = $derived(count * 2);

What I didn't 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's memoized. Which sounds better, until you're debugging and wondering why your console.log isn't 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'd 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();// 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's just let { class: className, ...rest } = $props() and everything behaves predictably. The migration guide wasn't lying when they said this reduces API surface area.

Slots to Snippets Broke My Brain

The biggest friction wasn't 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><!-- 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><!-- Card.svelte -->
<script>
	let { header, children } = $props();
</script>

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

The ?.() optional chaining matters — if you don't include it and the parent doesn't 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!// 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'd 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 doesn't 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"
	}
}{
	"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'll get cryptic errors about missing preprocessors or failed TypeScript compilation. The migration script will update it, but double-check — I've seen it miss this in projects with complex dependency trees.

I also had to bump the adapter — Cloudflare adapter 2.x doesn't 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 basically tracks SvelteKit minor versions, which is easier to remember but means you can't skip adapter updates anymore.

Node version matters too. SvelteKit 2 requires Node 18.13 or higher. If you're 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 doesn't 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's 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 wasn't rendering before realizing the parent component was still using <slot /> instead of {@render children?.()}. The reverse doesn't work: you can't pass slotted content to a component that expects snippets.

Also, the migration script leaves createEventDispatcher calls untouched because it's "too risky." So now I have a codebase that's half callback props, half event dispatchers, and I have to remember which components use which pattern. It's not unmanageable, but it's not the seamless upgrade the blog posts promised either.

What I Think Now

After a week of working with it, I'm 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's easier to test now.

But I'd recommend a different migration strategy than the one I used. Don't 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'm not 100% sure if Svelte 5 is actually faster for my use case. The bundle size dropped slightly — about 3KB gzipped — but that's in the noise for most apps. The real win, if there is one, will be in maintainability six months from now when I'm not debugging mysterious $: statement ordering issues.

FAQ

Q What dependencies do I need to update?
A You need Svelte 5.55+, SvelteKit 2.56+, @sveltejs/vite-plugin-svelte 5.x+ as a peerDependency, Vite 7.x+, TypeScript 5.x+, and updated adapter versions (Cloudflare 5.x, Netlify 5.x, Node 5.x, Vercel 5.x). Node 18.13+ is also required.
Q Can I mix Svelte 4 and Svelte 5 components in the same app?
A Yes, but carefully. You can use Svelte 4 components in a Svelte 5 app, but you cannot pass snippets to components that expect slots. The migration script converts your components, but third-party libraries might still use the old patterns.
Q Do I have to migrate everything at once?
A No, and you probably should not. The migration script can run on individual files. Start with simple components and work your way up to the complex ones with slots and events.
Q What about TypeScript?
A Native TypeScript support is actually one of the wins. No more svelte-preprocess configuration nightmares. But you will need to update your component prop types from export let prop: Type to the Component type from svelte if you are writing .d.ts files.
Q Should I wait for SvelteKit 3?
A SvelteKit works fine with Svelte 5 now. The $app/stores to $app/state change is a SvelteKit 2.12 feature, not SvelteKit 3. There is no need to wait.
Q What happens to my stores?
A They still work. But you might find yourself reaching for them less often now that you can use $state in .svelte.js and .svelte.ts files. I migrated several stores to plain functions with runes and did not miss the store API.
About the Author

Asaduzzaman Pavel

Software Engineer who actually enjoys the friction of well-architected systems. 15+ years building high-performance backends and infrastructure that handles real-world chaos at scale.

Open to new opportunities

Comments

  • Sign in with GitHub to comment
  • Keep it respectful and on-topic
  • No spam or self-promotion