SvelteKit 3 Migration Notes: Node 22, Vite 8 and What Actually Breaks
A beginner-friendly field guide to upgrading from SvelteKit 2 to SvelteKit 3 — what changed, why it changed, and the quiet failures a green build will not show you.
SvelteKit 3.0 shipped on October 1, 2026, and the official line is that it feels like the same framework “with a little more polish.” That is true for your code. It is not the whole story for your upgrade.
A SvelteKit 3 upgrade is really three upgrades stacked on top of each other: a new runtime (Node 22.17+), a new build tool (Vite 8, now powered by the Rust bundler Rolldown) and a new framework major (SvelteKit 3 itself). The official codemod handles most of the framework layer beautifully. The failures tend to hide in the layers around it — your package manager, your Vite config, and links or buttons written inline in markup.
In this post we will walk through each layer slowly, with before-and-after code, the theory behind every change, and diagrams you can come back to. If you have never done a major framework migration before, you are exactly the reader I wrote this for.
TL;DR
- Runtime: SvelteKit 3 needs Node 22.17 or newer, TypeScript 6, Svelte 5.57.1+ and Vite 8.0.12+. The npm bundled with Node 22 can crash while installing a fresh SvelteKit 3 project — use npm 11.6+, pnpm, or a newer Node line.
- Build tool: Vite 8 replaces the esbuild + Rollup pair with a single Rust bundler, Rolldown.
rollupOptionsbecomesrolldownOptions,esbuildbecomesoxc, and the object form ofmanualChunksis gone. - Framework:
svelte.config.jsis gone (config moves intovite.config.ts),$libbecomes#lib,$app/storesis removed,$app/environmentis now$app/env, environment variables are declared insrc/env.ts, anderror()takes a string message. - The real risk:
npx sv migrate sveltekit-3does most of the work, but it can leave broken inlinepushStatecalls and malformed{base}links. Always runnpm run check, build, and click through the app.
1. Why this is three upgrades, not one
Think of your SvelteKit app as a building with three floors. Your runtime (Node.js) is the foundation. Your build tool (Vite) is the plumbing and wiring that turns source files into something a browser and server can run. Your framework (SvelteKit) is the furnished floor you actually live on.
SvelteKit 3 raises the minimum version on every floor at once. That matters because each floor depends on the one below it. You cannot install Vite 8 on an unsupported Node version, and SvelteKit 3 will refuse to work with anything other than Vite 8.
The practical rule that falls out of this picture: upgrade from the bottom up. First get Node and your package manager right, then make sure Vite 8 is happy, then let the SvelteKit codemod rewrite your app code. If you do it in reverse, you will spend an afternoon debugging framework errors that are really install or bundler errors in disguise.
A note on why the Svelte team bundled these together. A framework major version is the only time a project can drop old runtimes and old APIs without surprising anyone. The SvelteKit 3 announcement describes the release as the same framework with more type safety and “a little less junk.” Most of that junk is legacy API surface (like Svelte 4-era stores) and config indirection (like a separate
svelte.config.js). Raising the floor on Node and Vite is what lets the team delete it.
2. The version matrix: what you need before you start
The official migration guide lists hard minimums. Here they are next to what most SvelteKit 2 projects run today, plus why each one exists.
A quick way to see where you stand:
node -v # should print v22.17.0 or higher
npm -v # 11.6.0+ recommended (see section 3)
npx tsc -v # Version 6.x.x
npm ls vite svelte @sveltejs/kit @sveltejs/vite-plugin-svelteWhat each command tells you. node -v and npm -v check your runtime floor. npx tsc -v runs the TypeScript compiler installed in your project (not a global one), so it reflects what svelte-check will use. npm ls prints the resolved version of each package from your lockfile, which is more truthful than reading the ranges in package.json. A range like ^7.0.0 tells you what is allowed; npm ls tells you what is installed.
Lock the runtime in your repo. Add an engines field and a packageManager field to package.json, so teammates and CI fail fast instead of half-installing:
{
"engines": { "node": ">=22.17" },
"packageManager": "npm@11.6.0"
}engines documents the minimum Node version, and npm warns when it does not match. packageManager is read by Corepack, which can download and pin the exact package manager for everyone on the team. Together they turn "works on my machine" into a rule the tooling enforces.
Upgrade to the latest 2.x first. The Svelte team recommends landing on the newest SvelteKit 2 release before jumping to 3. Late 2.x versions print targeted deprecation warnings for almost everything that is removed in 3, and SvelteKit 2.62+ already accepts configuration in vite.config.ts. Fixing those warnings on a version you know is far easier than fixing errors on one you don't.
3. Layer 1 — Node 22 and the npm install trap
The first thing that breaks is often not your code at all. It is npm install.
What a runtime floor really means
When a package declares "engines": { "node": ">=22.17" }, it is promising its code only uses features that exist in Node 22.17 and later. For SvelteKit 3 that means it can delete the old @sveltejs/kit/node/polyfills module, which used to patch missing web APIs (like fetch or crypto) onto older Node versions. Those APIs now ship in Node itself, so the shim is dead weight.
If you have a custom server, remove the import:
// server.js — SvelteKit 2 custom server
import '@sveltejs/kit/node/polyfills'; // ❌ delete this line in SvelteKit 3
import { handler } from './build/handler.js';And the getRequest / setResponse helpers from @sveltejs/kit/node are now synchronous, so drop the await:
// Before (SvelteKit 2)
const request = await getRequest({ base, request: req });
await setResponse(res, response);
// After (SvelteKit 3)
const request = getRequest({ base, request: req });
setResponse(res, response);Why remove await? Awaiting a non-promise value is harmless in JavaScript, so this would not crash. But it signals to readers (and linters) that something asynchronous is happening when it is not. Matching the new signature keeps your code honest and avoids a lint warning about awaiting non-thenables.
The trap: Node 22’s bundled npm
Here is the counter-intuitive part. An independent agency reproduced and documented that a fresh SvelteKit 3 project scaffolded with Vitest can crash during npm install on npm versions 10.8.2 through 11.5.0. The error comes from deep inside npm's dependency resolver and looks something like this:
npm error Cannot read properties of null (reading 'edgesOut')The crash disappears from npm 11.6.0 onward, and pnpm and Yarn install the same project fine. The catch is that every Node 22 release ships with an npm 10.x. So if you install Node 22 LTS and use the npm that comes with it, exactly as the docs suggest, a brand-new project may fail to install.
Why your CI stays green while laptops break. npm ci installs straight from your committed lockfile. It never computes a fresh dependency tree, so it never reaches the buggy code path. npm install without a lockfile (a new clone, a dependency bump, a Docker build) does compute the tree, and that is where the crash happens.
How to fix it
Pick one of these, in order of preference:
# Option A: upgrade npm itself (works on Node 22)
npm install -g npm@^11.6.0
# Option B: use pnpm via Corepack
corepack enable
pnpm install
# Option C: pin Vitest to a newer major that avoids the crash
npm install -D vitest@^5Avoid reaching for --legacy-peer-deps as a quick fix. It tells npm to ignore peer-dependency rules entirely, which can silently install a different version of a transitive package than everyone else gets. If someone on your team does use it, review the lockfile diff before merging.
Two more install-time surprises
- TypeScript 7 is not supported yet. SvelteKit 3 and
svelte-checkpeer on TypeScript^6. If your project already moved to TypeScript 7,npm installwill fail withERESOLVE. Stay on 6.x for now. - Third-party plugins that pin
@sveltejs/kit@^2. Any package that declares a peer dependency on SvelteKit 2 only will block the install. Search yourpackage.jsonand lockfile for packages that peer on@sveltejs/kitand check each one has a release that accepts^3.
# List every installed package that declares a peer on @sveltejs/kit
npm ls @sveltejs/kit --all4. Layer 2 — Vite 8, Rolldown and Oxc
Vite 8 is the biggest internal change in Vite’s history, yet for most SvelteKit apps it needs zero code changes. To understand when it does need changes, you need to know what a bundler does.
Bundler basics in 60 seconds
Your app is hundreds of small files that import each other. Browsers can load those files one by one, but that is slow in production. A bundler walks the import graph, combines files into a few optimised “chunks,” strips unused code (tree-shaking) and minifies the result. A transformer does the per-file work before that: turning TypeScript into JavaScript, lowering new syntax for older browsers, and so on.
Up to Vite 7, these jobs were split across two tools written in different languages: esbuild (Go) handled fast transforms and dependency pre-bundling during development, while Rollup (JavaScript) did the production build. Two engines meant two subtly different behaviours. Code that worked in npm run dev could break in npm run build.
Vite 8 replaces both with Rolldown, a Rust re-implementation of Rollup’s API. Oxc takes over JavaScript transforms and minification, and Lightning CSS minifies CSS. One engine for dev and prod means far fewer “only breaks in production” bugs, and teams migrating real SvelteKit sites have reported roughly 10–15% faster builds on average.
The minimum upgrade
If your vite.config.ts contains nothing but the SvelteKit plugin, the Vite part of the migration is only version bumps:
npm install -D vite@^8.0.12 @sveltejs/vite-plugin-svelte@^7 vitest@^4The easy one to miss is @sveltejs/vite-plugin-svelte. It jumps a major version (6 → 7) to support Vite 8. If you edit package.json by hand and leave ^6, npm will never pick up 7, and you will get a peer-dependency conflict.
Renamed config keys
If you customised Vite, rename these. The old names still work through a compatibility layer, but they print warnings and will be removed later.
// vite.config.ts — BEFORE (Vite 7)
export default defineConfig({
esbuild: { drop: ['console', 'debugger'] },
optimizeDeps: { esbuildOptions: { target: 'es2022' } },
build: {
rollupOptions: {
output: { manualChunks: { vendor: ['chart.js'] } }
}
}
});// vite.config.ts — AFTER (Vite 8)
export default defineConfig({
oxc: { /* JS transform options now live here */ },
optimizeDeps: { rolldownOptions: { /* ... */ } },
build: {
rolldownOptions: {
output: {
// manualChunks object form is removed; let Rolldown split automatically
minify: { compress: { dropConsole: true, dropDebugger: true } }
}
}
}
});Reading the diff line by line:
esbuild→oxc: the top-level transform config follows the tool that does the work. Note that droppingconsolecalls is a minification job, so it moved underbuild.rolldownOptions.output.minifyinstead ofoxc. Check the Vite migration guide for the exact option names in your Vite minor.optimizeDeps.esbuildOptions→optimizeDeps.rolldownOptions: dependency pre-bundling is now done by Rolldown too.build.rollupOptions→build.rolldownOptions: same idea, same shape. Rolldown aims to accept Rollup's options.manualChunks: { ... }(object form) is removed, and the function form is deprecated. Rolldown's automatic chunking is usually as good. If you truly need manual control, use Rolldown's newer code-splitting option.
The quiet breakage: CommonJS default imports
This is the one change in Vite 8 that type-checking will never catch. Some older npm packages publish CommonJS (module.exports = ...) instead of ES modules. When you write import foo from 'old-package', the bundler has to guess whether foo should be module.exports or module.exports.default.
Vite 7 could guess differently in dev (esbuild) and prod (Rollup). Vite 8 guesses the same way everywhere. That is better in the long run, but it means a package that “worked” before may now hand you undefined:
TypeError: Cannot read properties of undefined (reading 'format')Your TypeScript types are still correct, so svelte-check passes. Only running the code reveals it. Fixes, in order of preference: switch to the package's ES module build, replace the package with a maintained alternative, or as a short-term escape hatch:
// vite.config.ts — temporary only, remove before shipping
export default defineConfig({
legacy: { inconsistentCjsInterop: true }
});Small side effects worth knowing
- New chunk hashes. Rolldown names and hashes chunks differently, so your first deploy invalidates every cached JS file. Expect a cold cache, not a bug.
- CSS may be 1–3% larger. Lightning CSS lowers syntax differently from esbuild. Usually not worth acting on.
- Remove dead
rollupoverrides inpackage.json(for example, ones added for old Rollup CVEs). Vite 8 no longer uses Rollup, but runnpm auditafterwards: Rolldown pulls in a different tree of transitive dependencies.
5. Layer 3 — SvelteKit 3’s breaking changes, explained
This is where most of your code edits happen. I have grouped the changes by the question a beginner is likely to ask: “where does my config go?”, “how do I import my own files?”, “how do I read page state?” and so on.
5.1 Configuration moves into vite.config.ts
Why: In SvelteKit 2, configuration was split across two files. svelte.config.js held SvelteKit and Svelte compiler options; vite.config.ts held Vite options and loaded the SvelteKit plugin. Other Vite plugins could not easily see SvelteKit's settings. Now there is one file, and the SvelteKit plugin receives its options directly.
// svelte.config.js — SvelteKit 2 (delete this file)
import adapter from '@sveltejs/adapter-node';
export default {
compilerOptions: { runes: true },
kit: {
adapter: adapter(),
csrf: { checkOrigin: false },
prerender: { origin: 'https://example.com' }
}
};// vite.config.ts — SvelteKit 3
import { sveltekit } from '@sveltejs/kit/vite';
import adapter from '@sveltejs/adapter-node';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
sveltekit({
adapter: adapter(), // was kit.adapter
compilerOptions: { runes: true }, // same as before, now beside kit options
csrf: { trustedOrigins: ['https://checkout.stripe.com'] }, // replaces checkOrigin: false
paths: { origin: 'https://example.com' } // replaces prerender.origin and adapter-node's ORIGIN
})
]
});What changed, conceptually: everything that lived under kit: { ... } is now a top-level option of sveltekit({ ... }). Anything SvelteKit does not recognise is passed through to vite-plugin-svelte, so options like inspector go here too.
Three options deserve a closer look:
csrf.checkOrigin: falseis gone. CSRF (cross-site request forgery) protection rejects form posts that come from other websites. Turning it off globally was a common shortcut for payment or login providers that post back to your app. SvelteKit 3 makes protection always-on, and you allow-list the specific origins you trust withcsrf.trustedOrigins.paths.originreplaces bothprerender.originand adapter-node'sORIGINenv var. Behind a reverse proxy (Nginx, a load balancer), the server cannot always work out its public URL from request headers.paths.origintells it explicitly. It is also used for CSRF checks.preloadStrategy,vitePlugin,files.liband severalexperimental.*flags are removed. Delete them; the migrator does this for you.
5.2 $lib becomes #lib
Why: $lib was a SvelteKit-specific alias. Every tool (TypeScript, Vitest, ESLint, your editor) needed to be told about it. #lib uses Node's standard subpath imports: the imports field in package.json. Node, Vite and TypeScript all understand this field natively, so the alias "just works" everywhere.
// package.json
{
"imports": {
"#lib": "./src/lib/index.js",
"#lib/*": "./src/lib/*"
}
}// Before
import { formatDate } from '$lib/utils';
import Button from '$lib/components/Button.svelte';
// After - note the explicit file extension
import { formatDate } from '#lib/utils.ts';
import Button from '#lib/components/Button.svelte';The gotcha: file extensions are now required. The "#lib/*": "./src/lib/*" mapping is a simple text substitution. #lib/utils.ts becomes ./src/lib/utils.ts. But #lib/utils becomes ./src/lib/utils, which is not a file, and Node's resolver does not guess extensions the way bundler aliases did. The same goes for folders: write #lib/utils/index.ts, not #lib/utils. If your build fails with a module-resolution error after the migration, this is the first thing to check.
5.3 $app/stores is removed — use $app/state
Why: Svelte 5 replaced stores with runes (fine-grained reactive state). $app/state exposes page, navigating and updated as reactive objects. You read them directly, without the $ store prefix, and components only re-render when the exact property they read changes.
<!-- Before (SvelteKit 2, store-based) -->
<script>
import { page } from '$app/stores';
</script>
<p>You are on {$page.url.pathname}</p>
<!-- After (SvelteKit 3, rune-based) -->
<script>
import { page } from '$app/state';
</script>
<p>You are on {page.url.pathname}</p>Two behaviour changes come with it:
page.urlis now read-only. It is typed as aReadonlyURL, sopage.url.searchParams.set('q', 'x')is a type error. That code never actually changed the browser URL; it only mutated a shared object. If you need a mutable copy, make one:const url = new URL(page.url.href);updated.currentflips on its own. SvelteKit now checks for a new deployment on data requests, remote-function calls, when the tab regains focus, and every hour by default (version.pollInterval). If you show a "New version available — reload" banner, it will appear more often. That is the feature working as intended.
5.4 $app/environment is now $app/env
A straight rename. browser, dev, building and version keep their meaning:
// Before
import { browser, dev } from '$app/environment';
// After
import { browser, dev } from '$app/env';5.5 Environment variables are declared in src/env.ts
Why: In SvelteKit 2 you chose between four modules ($env/static/private, $env/static/public, $env/dynamic/private, $env/dynamic/public) and named public variables with a PUBLIC_ prefix. Nothing checked that a required variable actually existed, so a missing API key surfaced as a runtime crash in production. SvelteKit 3 promotes the "explicit environment variables" feature from experimental: you declare every variable once, with validation.
// src/env.ts
import { defineEnvVars } from '@sveltejs/kit/env';
import * as v from 'valibot';
export const variables = defineEnvVars({
// Private by default: only importable from server code
DATABASE_URL: {
schema: v.pipe(v.string(), v.url()),
description: 'Postgres connection string'
},
// Public: safe to ship to the browser
SITE_NAME: { public: true },
// Plain function instead of a schema library: return the value or throw
PORT: {
schema: (value) => {
if (value === undefined) return 3000;
const port = Number(value);
if (!Number.isInteger(port)) throw new Error('PORT must be an integer');
return port;
}
},
// static: inlined at build time, enabling dead-code elimination
SHOW_DEBUG_OVERLAY: {
public: true,
static: true,
schema: v.pipe(v.optional(v.string(), ''), v.transform((s) => s !== ''))
}
});// src/routes/+page.server.ts
import { DATABASE_URL, PORT } from '$app/env/private'; // server-only
// src/routes/+page.svelte
import { SITE_NAME } from '$app/env/public'; // browser-safeHow to read this file:
- Private is the default. A variable without
public: truecan only be imported from$app/env/private, and SvelteKit refuses to include that module in browser code. Secrets cannot leak into a JavaScript bundle by accident. schemavalidates and transforms. It accepts any Standard Schema library (Valibot, Zod) or a plain function. Notice thatPORTcomes out as a number, not a string, and gets a default. If validation fails, the app refuses to build or start, so a missing secret is caught at deploy time instead of by your users.static: truemeans "bake it into the build." Without it, values are read when the server boots (dynamic). With it, the value is inlined, and the bundler can delete code behind{#if SHOW_DEBUG_OVERLAY}entirely when it is false.descriptionshows up when you hover the variable in your editor. Free documentation.
The old $env/* modules still work in SvelteKit 3 but are deprecated and scheduled for removal in SvelteKit 4. Migrate now while you are touching the code anyway.
5.6 Links: base is gone, use resolve() and asset()
Why: Building URLs by string concatenation (base + '/blog/' + slug) is error-prone and untyped. resolve() takes a route ID plus params and returns a correct, type-checked path that already includes your base path.
<script>
// Before
import { base, assets } from '$app/paths';
// After
import { resolve, asset } from '$app/paths';
</script>
<!-- Before -->
<a href="{base}/blog/{post.slug}">Read</a>
<img src="{assets}/logo.png" alt="Logo" />
<!-- After -->
<a href={resolve('/blog/[slug]', { slug: post.slug })}>Read</a>
<img src={asset('logo.png')} alt="Logo" />Note the subtle rule: route IDs start with / ('/blog/[slug]'), but plain pathnames and asset paths do not (resolve('about'), asset('logo.png')). Remember this rule — it is the root of one of the bugs in section 7.
5.7 Navigation: pushState folds into goto
Why: Shallow routing (changing the URL and page state without loading a new page, for modals or tabs) had its own functions. SvelteKit 3 puts everything behind one API.
import { goto, refreshAll } from '$app/navigation';
// Before
pushState('/photos/42', { showModal: true });
replaceState('/photos/42', { showModal: true });
goto('/', { invalidateAll: true, noScroll: true, keepFocus: true, replaceState: true });
await invalidateAll();
// After
goto('/photos/42', { shallow: true, state: { showModal: true } });
goto('/photos/42', { shallow: true, replace: true, state: { showModal: true } });
goto('/', { refreshAll: true, reset: false, replace: true });
await refreshAll();Option by option:
shallow: true= update the URL andpage.statewithout navigating (oldpushState). AddpersistState: trueto keep that state across a page reload.replaceState→replace: overwrite the current history entry instead of adding one.noScroll+keepFocus→reset: false: one switch for "don't reset scroll and focus."invalidateAll→refreshAll: re-run everyloadfunction. Unlike the old one, it does not wipepage.state, which is what you want when a modal is open.
Two behaviour changes to know: goto() now rejects (throws) for URLs that do not match a route in your app, so use window.location.href = url for external sites. And shallow navigations now fire beforeNavigate, onNavigate and afterNavigate. Check navigation.shallow inside those hooks if you need to ignore them.
5.8 Errors: simpler error(), louder handleError
import { error } from '@sveltejs/kit';
// Before: message could be a string OR an object
error(404, { message: 'Post not found', code: 'POST_404' });
// After: message is always a string; extra fields go third
error(404, 'Post not found', { code: 'POST_404' });The fuller story:
App.Erroralways has astatusnow, so your+error.sveltecan show it without extra plumbing.handleErrorsees every error, including the "expected" ones you throw witherror(). In SvelteKit 2 it only saw unexpected crashes. If you log to Sentry or similar insidehandleError, filter on status or you will flood your error tracker with 404s.- Rendering errors are caught. Every
+error.svelteautomatically becomes a Svelte error boundary, so a crash inside a component shows your error page instead of a blank screen. json()andtext()helpers are deprecated. Use the web standard instead:return Response.json({ ok: true })orreturn new Response('hi').- Form
fail(400, ...)now really returns 400 to enhanced forms instead of 200. Tests that assert on status codes will need updating.
5.9 Smaller changes that still bite
The external-redirect change is a security improvement worth understanding. An open redirect happens when an attacker can make your site send users to a URL of their choosing, often from a query parameter like ?next=. Requiring { external: true } (or an allow-list of origins) forces you to make that choice deliberately:
import { redirect } from '@sveltejs/kit';
redirect(307, 'https://accounts.example.com/login', {
external: ['https://accounts.example.com'] // only this origin is allowed
});6. Running sv migrate, step by step
You do not have to make most of the edits in section 5 by hand. The Svelte CLI (sv, which reached 1.0 alongside SvelteKit 3) ships a task-based migration that rewrites your project. Here is the full workflow, from clean branch to merge.
Step 1 — Start from a clean, committed branch
git checkout -b upgrade/sveltekit-3
git status # must say "nothing to commit, working tree clean"Why it matters: the migrator edits files in place. A clean tree means git diff shows exactly what the tool changed, and git checkout . undoes it if you need to start over.
Step 2 — Land on the latest SvelteKit 2 and fix warnings
npm install -D @sveltejs/kit@^2 && npm run devClick around the app and read the terminal. Every deprecation warning you fix here is one less mystery error after the jump.
Step 3 — Get the runtime right
nvm install 22 && nvm use 22 # or fnm / volta / your Docker base image
npm install -g npm@^11.6.0
node -v && npm -vUpdate your CI config and Dockerfile in the same commit. A common miss is a FROM node:20-alpine line that nobody remembers until the deploy fails.
Step 4 — Preview, then run, the migration
# See the list of tasks (12 at the time of writing) without changing anything
npx sv@latest migrate sveltekit-3 --tasks
# Run every task, skip the confirmation prompt, then install with npm
npx sv@latest migrate sveltekit-3 --tasks all --confirm --install npmWhat the flags mean: --tasks on its own lists the available tasks. --tasks all selects every task (you can also name individual ones to migrate in smaller, reviewable commits). --confirm skips the interactive prompt. --install npm reinstalls dependencies afterwards with the package manager you name.
Get Vasanthan K’s stories in your inbox
Join Medium for free to get updates from this writer.
In practice the migrator bumps your dependencies, folds svelte.config.js into vite.config.ts, adds the imports field and rewrites $lib to #lib, swaps $app/stores for $app/state and $app/environment for $app/env, creates src/env.ts, moves param matchers into src/params.ts, and points tsconfig.json at $app/tsconfig. A typical diff after a successful run looks like this:
- import { page } from '$app/stores';
- import { browser } from '$app/environment';
- 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 { resolve } from '$app/paths';
+ import { PUBLIC_SITE_NAME } from '$app/env/public';
+ import { greet } from '#lib/greet.js';Read the diff before moving on. Commit it on its own (git commit -am "chore: sv migrate sveltekit-3"), so your manual fixes in the next step are reviewable separately.
Step 5 — Work through MIGRATION_TASKS.md
Some changes need a human decision, so the migrator writes them into a MIGRATION_TASKS.md file in your project root instead of guessing. Typical entries cover goto destinations, the new error() signature, invalidateAll → refreshAll, dev-server CORS for static files and the deprecated json() helper. The file is written step by step, so you (or a coding agent) can work through it in order. Delete it when you are done.
Steps 6–8 — Verify three different ways
Covered in detail in section 8. The short version: npm run check, then npm run build, then actually use the app. Each step catches a different class of bug, which is the point of the next section.
7. What actually breaks: the gotchas a green build hides
The migrator is good. A developer who tested it on a deliberately old-fashioned SvelteKit 2 app found it handled every import rename and the build passed in under half a second. Then two things broke in the browser. Both share a root cause worth understanding.
Why codemods miss things: script vs markup
A codemod is a program that parses your source code into a tree, finds patterns, and rewrites them. Inside a <script> block, a call like pushState(...) is an ordinary JavaScript function call, easy to find and rewrite. Inside a Svelte template, the same call may sit in an inline event handler or inside a quoted HTML attribute string. Those are much harder to transform safely, so a cautious codemod may remove the import but leave the call. The result compiles, because templates are checked less strictly than scripts, and fails only when someone clicks.
Gotcha 1 — inline pushState with no import
<script>
import { goto } from '$app/navigation'; // migrator removed pushState from here...
</script>
<!-- ...but left the call in the markup. Clicking throws: pushState is not defined -->
<button onclick={() => pushState('', { modal: true })}>Open modal</button>The fix is the new goto form:
<button onclick={() => goto('', { shallow: true, state: { modal: true } })}>
Open modal
</button>npm run check (svelte-check) does catch this one, with an error like Cannot find name 'pushState'. The lesson: never skip check just because build passed.
Gotcha 2 — {base} inside a quoted href
This is the sneaky one. Suppose you wrote a link like this in SvelteKit 2:
<a href="{base}/blog/hello-world">First post</a>The migrator can turn it into:
<a href="{resolve('')}/blog/hello-world">First post</a>After hydration (the moment SvelteKit’s JavaScript takes over the server-rendered HTML in the browser), resolve('') returns an empty string when there is no base path. The link becomes //blog/hello-world. A URL that starts with two slashes is a protocol-relative URL: it means "same protocol, different host." The browser tries to visit a server called blog, and you get "This site can't be reached."
Neither svelte-check nor the build complains, because "{resolve('')}/blog/hello-world" is a perfectly valid string. The fix is to use the route ID form that resolve() was designed for:
<a href={resolve('/blog/[slug]', { slug: 'hello-world' })}>First post</a>Interestingly, links written as template literals (href={`${base}/about`}) were migrated correctly to resolve('about'). Whether you hit this bug depends on how you happened to write your links. Search for the risky pattern before you migrate:
# Find {base} used inside quoted attributes
grep -rnE '="\{base\}' src/
# Find shallow-routing calls in markup
grep -rnE '(pushState|replaceState)\(' src/ --include=*.svelteGotcha 3 — param matchers lose their types
When matchers move from src/params/*.ts into a single src/params.ts, the parameter type can be dropped, and svelte-check reports Parameter 'param' implicitly has an 'any' type. Add the annotation back:
// src/params.ts
import { defineParams } from '@sveltejs/kit/params';
export const params = defineParams({
slug: (param: string) => (/^[a-z0-9-]+$/.test(param) ? param : undefined)
});Notice the new contract: a matcher returns the parsed value when it matches and undefined when it does not, instead of returning true or false. That lets a matcher both validate and convert (for example, turn "42" into the number 42).
Gotcha 4 — everything from section 3 and 4
To keep the list in one place: the npm 10 install crash, TypeScript 7 conflicts, third-party plugins pinned to @sveltejs/kit@^2, and CommonJS default imports that turn into undefined under Vite 8. None of these appear in svelte-check or build output.
Read the matrix column by column and the pattern is obvious: the build column is empty. A successful build proves your code compiles under Vite 8. It says nothing about whether your links point to the right host or whether a button handler references a function that still exists.
8. Verifying the migration: check, build, then use it
Each of these three checks answers a different question. Run all of them.
Check 1 — npm run check asks "do my types agree?"
npm run check
# svelte-check found 0 errors and 0 warningssvelte-check runs the TypeScript compiler over your .ts files and your .svelte templates. It catches missing imports (Gotcha 1), lost types (Gotcha 3), mutations of the now read-only page.url, and old error(404, { message }) calls. Because SvelteKit 3 generates a stricter $app/tsconfig, expect a few new errors even in code the migrator never touched. Those are usually real bugs that were hiding.
Check 2 — npm run build asks "does it bundle under Vite 8?"
npm run build && npm run previewThe build exercises Rolldown, your adapter and your src/env.ts validation. If a required environment variable is missing, the build stops here, which is exactly what you want. Read the output for PLUGIN_TIMINGS warnings too: Rolldown reports how much build time each Vite plugin consumes, which is useful if the upgrade did not feel faster.
Check 3 — a smoke test asks “does it actually work in a browser?”
The two worst bugs in section 7 only appear after hydration in a real browser. You can catch them with a manual click-through, or automate it with a tiny Playwright script. This one opens each page you list, waits for hydration, flags any link that points off-site and any JavaScript error, then clicks every button.
// scripts/smoke.mjs — run against `npm run preview` (default port 4173)
import { chromium } from 'playwright';
const BASE = process.env.BASE_URL ?? 'http://localhost:4173';
const PAGES = ['/', '/blog', '/about']; // the routes you care about
const problems = [];
const browser = await chromium.launch();
const page = await browser.newPage();
// 1. Record uncaught JavaScript errors (e.g. "pushState is not defined")
page.on('pageerror', (err) => problems.push(`[${page.url()}] JS error: ${err.message}`));
for (const path of PAGES) {
// 2. 'networkidle' waits until hydration has finished and links are final
await page.goto(BASE + path, { waitUntil: 'networkidle' });
// 3. Every <a href> should stay on our own origin (catches //blog/... links)
const hrefs = await page.$$eval('a[href]', (links) => links.map((a) => a.href));
for (const href of hrefs) {
if (new URL(href).origin !== new URL(BASE).origin) {
problems.push(`[${path}] link leaves the site: ${href}`);
}
}
// 4. Click every visible button and let any errors surface
for (const button of await page.getByRole('button').all()) {
if (await button.isVisible()) await button.click({ trial: false }).catch(() => {});
if (page.url() !== BASE + path) await page.goto(BASE + path, { waitUntil: 'networkidle' });
}
}
await browser.close();
console.log(problems.length ? problems.join('\n') : 'Smoke test passed');
process.exit(problems.length ? 1 : 0);How it works, line by line:
page.on('pageerror')subscribes to uncaught exceptions inside the page. A button that calls a missingpushStatethrows here, even though the build was green.waitUntil: 'networkidle'matters more than it looks. The server-rendered HTML for Gotcha 2 is actually fine; the broken//blog/...href only appears once SvelteKit hydrates and re-renders the link. Waiting for the network to go quiet gives hydration time to finish.a.href(the DOM property, not the attribute) returns the fully resolved URL the browser would visit. Comparing itsoriginwith ours flags any link that would leave the site. If your app links to external sites on purpose, add an allow-list here.- Clicking buttons fires every inline handler once. If a click navigates away, the script returns to the page so the remaining buttons still get tested.
Run it in two terminals:
npm i -D playwright && npx playwright install chromium
npm run build && npm run preview # terminal 1
node scripts/smoke.mjs # terminal 2This is a smoke test, not a test suite. On a larger app, the same three ideas (listen for page errors, check link origins after hydration, exercise interactive elements) belong in your existing end-to-end tests.
Bonus check — reproduce a fresh install
Install-time failures never reach any of the checks above, because your machine already has a working node_modules. Simulate a new teammate:
git clone <your-repo> /tmp/fresh && cd /tmp/fresh
rm -f package-lock.json
npm install # must succeed on the npm version your team actually uses9. The copy-paste checklist
Before the migration
- Branch created, working tree clean
- Upgraded to the latest SvelteKit 2.x and fixed every deprecation warning
- Node 22.17+ locally, in CI and in every
Dockerfile - npm 11.6+ (or pnpm/Yarn);
enginesandpackageManagerset inpackage.json - TypeScript pinned to 6.x
- Third-party packages that peer on
@sveltejs/kit@^2have a 3-compatible release - Grepped for
{base}in quoted attributes and inlinepushState/replaceState
During the migration
npx sv@latest migrate sveltekit-3 --tasksreviewednpx sv@latest migrate sveltekit-3 --tasks all --confirmrun and committed on its ownvite,@sveltejs/vite-plugin-svelte(7) andvitest(4+) bumpedrollupOptions→rolldownOptions,esbuild→oxc, objectmanualChunksremoved- Every item in
MIGRATION_TASKS.mddone, then the file deleted
After the migration
npm run checkreports 0 errorsnpm run buildsucceeds with all environment variables validated- Smoke test or manual click-through: no off-site links, no JS errors
- Fresh clone +
npm installwithout a lockfile succeeds handleErrorfilters expected errors so your error tracker is not flooded- External
redirect()calls pass{ external: ... }
Wrapping up
SvelteKit 3 is a genuinely pleasant upgrade at the code level. One config file instead of two, a standard #lib import, rune-based page state, validated environment variables and a single Rust-powered bundler for dev and production all make day-to-day work simpler.
The risk has simply moved. It no longer lives in your components; it lives in the layers around them: the npm your Node version happens to bundle, the peer ranges of your plugins, and URLs built inside strings that no compiler inspects. Upgrade bottom-up, run all three checks, and click through the app before you merge. Ten minutes of clicking is cheap insurance against a link that sends users to a website called blog.
One last note: remote functions, SvelteKit’s type-safe way to call server code from the client, are still experimental in 3.0 and need both experimental.remoteFunctions and Svelte's async compiler flag. That is a topic for its own post.
References
- SvelteKit 3 is here — Svelte blog, October 1, 2026
- Migrating to SvelteKit v3 — official migration guide
- Environment variables — SvelteKit docs
- @sveltejs/kit releases — GitHub
- Vite v7 to v8 migration guide — Vite docs
- Migrating 8 SvelteKit Sites to Vite 8 in a day — Rick Cogley
- SvelteKit 3 Requires Node 22. The npm in Node 22 Cannot Install It. — REPTILEHAUS
- SvelteKit 3 Upgraded My App in One Command. Two Things Broke. — Yash Rastogi
- SvelteKit 3 just works on Netlify — Netlify changelog








