DEV Community

Asaduzzaman Pavel
Asaduzzaman Pavel

Posted on • Originally published at iampavel.dev

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;
Enter fullscreen mode Exit fullscreen mode

Into this:

let count = $state(0);
const doubled = $derived(count * 2);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

In Svelte 5, that becomes:

<!-- Card.svelte -->
<script>
    let { header, children } = $props();
</script>

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

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!
Enter fullscreen mode Exit fullscreen mode

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)