Migrating from Svelte 4 to 5, one component at a time
If you have an app running on Svelte 4, moving to Svelte 5 can look like a lot of work. There are runes to learn, component events to update, and slots to replace with snippets. But you don't have to change all of that at once.
Svelte 5 supports the old component syntax alongside the new one. I'd start by getting the existing app working on Svelte 5, then convert components a little at a time. That makes it easier to understand what broke if something stops working.
In this article, we'll follow that approach with a small issue tracker. We'll convert a component to runes and look at a mistake that passes the build but produces the wrong HTML on the server.
The example app
The companion repository contains three versions of the same app: 01-svelte4, 02-svelte5-legacy, and 03-svelte5-runes. You can compare them without having to undo changes between steps.
The app displays issues, filters them through the URL, and has a detail dialog and a form with server validation. It's a teaching example; form submissions aren't saved to a database.
We'll focus on the summary above the issue list. It receives the issues from the page and displays how many are marked as high priority. The page stays in legacy syntax throughout the example.
Get the existing app running on Svelte 5
Before changing anything, run your existing tests and build. You'll want to know whether a failure was already there before the upgrade.
Next, check the packages that work with Svelte: the Vite plugin, component libraries, adapter, and any preprocessors you use. Updating only the svelte package can leave you with incompatible dependencies.
That happened in this example with @sveltejs/vite-plugin-svelte 3.1.2. Its own peer range accepts Svelte 5, but it depends on svelte-hmr 0.16.0, whose range stops at Svelte 4. The Svelte 5 versions of the app therefore update both Svelte and the plugin.
The intermediate version keeps the component source unchanged. Once its checks pass, we can look at the runes conversion separately. Your app may need fixes at this point: legacy syntax support doesn't remove the breaking changes in Svelte 5.
Convert the summary component
The summary uses this Issue type from src/lib/types.ts:
type Issue = {
id: string;
title: string;
status: 'open' | 'closed';
priority: 'high' | 'normal';
};
export type { Issue };
Here's the Svelte 4 component:
<script lang="ts">
import type { Issue } from '$lib/types';
export let issues: Issue[];
$: urgent = issues.filter((issue) => issue.priority === 'high').length;
</script>
<p data-testid="summary">{issues.length} issues; {urgent} urgent</p>
The $: declaration recalculates urgent when issues changes. For the open-issue filter, the example has two issues, one of them high priority. The summary displays 2 issues; 1 urgent.
In Svelte 5, we can write the component like this:
<script lang="ts">
import type { Issue } from '$lib/types';
type Props = { issues: Issue[] };
let { issues }: Props = $props();
let urgent = $derived(issues.filter((issue) => issue.priority === 'high').length);
</script>
<p data-testid="summary">{issues.length} issues; {urgent} urgent</p>
$props() gives us the issues prop. The count is calculated from that prop, so it belongs in `$derived`. When the page passes a different list, the count updates too. We don't need another variable to keep track of it.
The markup hasn't changed, and neither has the page that uses this component. Switching to closed issues still updates the summary, even though the parent uses legacy syntax and the child uses runes.
Why not use $effect here?
A reactive declaration can also run side effects, so it might be tempting to replace $: with $effect. Let's see what happens if we use this in place of the derivation:
let urgent = $state(0);
$effect(() => {
urgent = issues.filter((issue) => issue.priority === 'high').length;
});
This version builds. If you open it in the browser, the count also looks correct once JavaScript runs. But `$effect` only runs in the browser, so the server renders the initial value of urgent, which is zero.
The response contains 2 issues; 0 urgent. After hydration, the browser changes it to 2 issues; 1 urgent. You could easily miss the incorrect count by checking only the page after it loads.
The repository deliberately introduces this mistake in a separate copy of the component. It gives us a small, reproducible way to check whether our tests would catch it.
For this component, $derived expresses what we need: a count calculated from the current issues, including when the server renders the page. An effect would be useful for browser work such as drawing to a canvas, but we don't need one to calculate this value.
Test the HTML as well as the browser
The example uses Playwright to request the page directly and inspect the response:
const open = await request.get('/?status=open');
expect(open.status()).toBe(200);
const openHtml = await open.text();
expect(openHtml).toMatch(/2 issues; 1 urgent/);
This assertion fails for the version using $effect. Checking the response status alone wouldn't catch the problem, because the server still returns HTTP 200.
The browser tests then check what happens when someone uses the app: changing the filter, navigating back and forward, opening and closing the dialog, and submitting the form. These checks cover behavior that a successful build can't tell us about, such as whether a validation error leaves the user's input in place.
The recorded test run passed four tests against each version, twelve in total, with browser interactions tested in Chromium. The same checks run before and after the conversion, so we can compare the results.
For your own app, start with the interactions you need to keep working. Include a direct HTML check when the component displays server-rendered data. You can find a longer introduction to writing these tests in E2E testing with SvelteKit and Playwright.
Using the migration tool
The summary in this example was converted manually to make the change easy to follow. For a larger app, Svelte also provides a migration command:
npx sv migrate svelte-5
Run it with a clean working tree so you can review the changes. It handles many of the repetitive edits, but some code still needs attention. For example, createEventDispatcher and the old beforeUpdate and afterUpdate hooks aren't automatically converted.
You may also see imports from svelte/legacy. Check those before replacing them: the tool can use run for reactive statements it cannot safely convert. Changing that to $effect without understanding the calculation can introduce the server-rendering problem we just looked at. The migration script documentation explains what to review.
Running the example yourself
Follow the repository setup instructions to install and build the three versions. The repository pins package versions for reproduction and includes the full test suite and a local rollback exercise. The Svelte 4 build has known missing-export warnings, documented in the test evidence; the recorded tests cover the flows described above.
For a deployed app, I'd keep the dependency upgrade and the component conversion in separate releases. Test each on your hosting setup and make sure you can restore the previous working release. The repository's rollback exercise runs locally, so you'll need to adapt that procedure to your host.
Wrapping up
You can take your time converting components once the app works on Svelte 5. Start with one you understand, check the values it renders and the interactions around it, then move on to the next.
In this example, the conversion is small. Understanding why the count belongs in $derived is the useful part: it explains both how to write the component and what to test.
If you're stuck on a dependency or a component that behaves differently after migration, you can bring the code to a Svelte consulting session.