SvelteKit 3 Upgraded My App in One Command. Two Things Broke.
The build passed. Then a link sent my browser to a site that doesn’t exist. Here’s what the official migrator missed, and the 3 checks that catch it.
My build went green. Then I clicked a link and Chrome tried to open http://blog/hello-world, a website that doesn't exist.
That broken link appeared a few minutes after I ran the official upgrade command for SvelteKit 3, which shipped on October 1. The migrator did most of the work well. It also left two bugs that npm run build never noticed.
What changed in SvelteKit 3
SvelteKit 3 is the first major version since SvelteKit 2 in December 2023. Most changes are renames and cleanups:
- Config moves from
svelte.config.jsintovite.config.ts. $libbecomes#lib, a standard Node subpath import declared inpackage.json.$app/storesis gone (use$app/state), and$app/environmentis renamed to$app/env. Env vars now come from$app/env/publicand$app/env/private.basefrom$app/pathsis removed. You build links withresolve()instead.pushState()becomesgoto(url, { shallow: true, state }).error(404, { message })becomeserror(404, 'message').- You need Node 22.17+, TypeScript 6, Svelte 5.57.1+ and Vite 8.
My test app
A fresh template has nothing to migrate, so it can’t tell you much. I built a small SvelteKit 2.70.3 app packed with v2 habits: $lib imports, the $page store, $env/static/public, base in a link, a pushState button, a param matcher, error() with an object, and json() in an API route.
The home page looked like this:
<script lang="ts">
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { goto, pushState } from '$app/navigation';
import { base } from '$app/paths';
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { greet } from '$lib/greet';
</script>
<h1>{PUBLIC_SITE_NAME}</h1>
<p>{greet('Svelte')} You are on {$page.url.pathname}. Browser: {browser}</p>
<a href="{base}/blog/hello-world">First post</a>
<button onclick={() => pushState('', { modal: true })}>Open modal</button>
<button onclick={() => goto('/', { invalidateAll: true })}>Reload data</button>On SvelteKit 2 it built, the link opened the post, and both buttons worked.
Running the migrator
The Svelte CLI (sv) has a migration with 12 tasks. These are the only commands you need:
# Commit first: the migrator edits your files in place
git commit -am "before sveltekit 3"
# List the 12 migration tasks
npx sv@latest migrate sveltekit-3 --tasks
# Run all of them, skip the prompt, install with npm
npx sv@latest migrate sveltekit-3 --tasks all --confirm --install npm
# Then the two checks that matter
npm run check # svelte-check (type errors)
npm run buildI used sv 1.0.1. It took about 3 seconds and ended with All tasks applied successfully!. It bumped @sveltejs/kit to ^3.0.0, moved my param matcher into src/params.ts, created src/env.ts, and rewrote my imports:
- import { page } from '$app/stores';
- import { browser } from '$app/environment';
- import { goto, pushState } from '$app/navigation';
- import { base } from '$app/paths';
- import { PUBLIC_SITE_NAME } from '$env/static/public';
- import { greet } from '$lib/greet';
+ import { page } from '$app/state';
+ import { browser } from '$app/env';
+ import { goto } from '$app/navigation';
+ import { resolve } from '$app/paths';
+ import { PUBLIC_SITE_NAME } from '$app/env/public';
+ import { greet } from '#lib/greet.js';It also wrote a MIGRATION_TASKS.md with 5 things it won't change for you: goto destinations, the new error() signature, invalidateAll to refreshAll, CORS for static files in dev, and the deprecated json(). The file is written so an AI coding agent can work through it, step by step.
Then npm run build: ✓ built in 309ms. Green. But look at the third line of that diff: pushState is gone from the imports.
Bug 1: a button that throws
The import was gone, but the markup still called it:
<button onclick={() => pushState('', { modal: true })}>So clicking Open modal threw pushState is not defined. The build didn't flag it.
I tested a second version to see why. When pushState sits inside a function in the <script> block, the migrator rewrites it correctly to goto('/modal', { shallow: true, state: { modal: true } }). When the call is inline in the markup, it only removes the import.
The good news: npm run check catches this one. It also caught a type the migrator dropped when it moved my param matcher:
ERROR "src/params.ts" 3:20 "Parameter 'param' implicitly has an 'any' type."
ERROR "src/routes/+page.svelte" 13:24 "Cannot find name 'pushState'."
COMPLETED 177 FILES 2 ERRORS 0 WARNINGS 2 FILES_WITH_PROBLEMSBug 2: a link to a website called “blog”
This one is sneakier. My link used base inside a quoted attribute, and the migrator turned it into href="{resolve('')}/blog/hello-world".
In the browser, once SvelteKit’s JavaScript takes over the page (this is called hydration), resolve('') returns an empty string, so the link becomes //blog/hello-world. A URL that starts with two slashes means "same protocol, different host". So the browser looked for a server named blog, and Chrome showed "This site can't be reached".
Get Yash Rastogi’s stories in your inbox
Join Medium for free to get updates from this writer.
svelte-check reported nothing. The build was green. Only a real click found it.
The same base written as a template literal, href={`${base}/about`}, was migrated correctly to resolve('about'). So whether you hit this depends on how you wrote your links.
The fix
Three lines in the markup, written by hand:
<a href={resolve('/blog/[slug=word]', { slug: 'hello-world' })}>First post</a>
<button onclick={() => goto('', { shallow: true, state: { modal: true } })}>Open modal</button>
<button onclick={() => goto('/', { refreshAll: true })}>Reload data</button>Then three small edits from MIGRATION_TASKS.md: error(404, 'Post not found'), Response.json({ message: 'hi' }) instead of json(), and (param: string) in src/params.ts. After that, svelte-check reported 0 errors, the build passed, and the link and both buttons worked.
A 20-line smoke test
svelte-check can’t tell a good URL from a bad one. So I wrote a tiny Playwright script that opens the home page, flags any link that leaves the site, and clicks every button:
// smoke.mjs: run after `npm run build && npm run preview`
import { chromium } from 'playwright';
const site = process.env.URL ?? 'http://localhost:4173';
// CHROME is optional: a path to a Chrome you already have
const browser = await chromium.launch({ executablePath: process.env.CHROME });
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto(site, { waitUntil: 'networkidle' }); // wait for hydration
const links = await page.$$eval('a[href]', (as) => as.map((a) => a.href));
for (const href of links) {
if (new URL(href).origin !== new URL(site).origin) errors.push(`leaves the site: ${href}`);
}
for (const button of await page.getByRole('button').all()) await button.click();
console.log(errors.length ? errors : 'smoke test passed');
await browser.close();
process.exit(errors.length ? 1 : 0);Install it with npm i -D playwright and npx playwright install chromium. Run npm run build && npm run preview in one terminal and node smoke.mjs in another. Clicking every button is fine for one small page. On a real app, use your end-to-end tests.
The networkidle wait matters. Before hydration, the server HTML had .//blog/hello-world, which stays on your site. The bad //blog/... link only appears once SvelteKit takes over in the browser.
On the unfixed app it reported both bugs: leaves the site: http://blog/hello-world and pushState is not defined. On the fixed app it printed smoke test passed.
Each check catches a different kind of mistake. You want all three.
Should you upgrade?
New projects: yes. npx sv create my-app starts you on SvelteKit 3 today. One Vite config, #lib and $app/state are simpler than what they replace.
Existing apps: yes, with a checklist. The migrator handled every import rename. Both runtime bugs were in the markup. So:
- Run it on a branch with a clean git tree.
- Before you start, search your
.sveltefiles for{base}inside quoted attributes and forpushState(orreplaceState(in the markup. Those are the two patterns that broke for me. - Run
npm run check, not just the build. - Click through the app, or run a smoke test like the one above.
- Work through
MIGRATION_TASKS.md, then delete it.
One limit to know: remote functions, SvelteKit’s type-safe way to call the server from the client, are still experimental and need the async Svelte flag turned on.
The takeaway
A green build only proves your code compiles. After a big migration, bugs hide where the compiler doesn’t look, like URLs built inside strings. Ten minutes of clicking around before you merge is cheap insurance.
Have you run sv migrate on a real app yet? Tell me in the comments what it missed for you.
I test new developer tools the week they launch and write up what worked and what broke, in plain language. Follow me so you get the next one.
Sources
- SvelteKit 3 is here, Svelte blog, October 1, 2026
- Migrating to SvelteKit v3, official migration guide
- @sveltejs/kit 3.0.0 release, GitHub
- sv on npm, the Svelte CLI (1.0.1 used here)
- Hacker News discussion









