SvelteKit 3 changelog and migration guide
Aug 13, 2026 SvelteKit
SvelteKit 3 deletes some old APIs, moves your project config out of svelte.config.js and into the Vite plugin, and needs newer Node, TypeScript, Svelte and Vite. Most apps will spend the upgrade in search-and-replace, not in redesign.
This follows the official SvelteKit 3 migration guide. Check that page again if a later 3.x release adds more.
Table of Contents
Upgrade in this order
- Get onto the latest SvelteKit 2.x first. 2.62 already accepts Vite-plugin config, and recent 2.x releases print deprecation warnings for the APIs that 3.0 removes.
- Bump the runtime and tooling. SvelteKit 3 will not start below these:
- Node 22.17
- TypeScript 6
- Svelte 5.56.4
- Vite 8.0.12 (Vite 8 ships stable Rolldown)
@sveltejs/vite-plugin-svelte7
- Run the official migrator, then fix whatever it leaves behind:
npx sv migrate sveltekit-3 sv migrate rewrites $app/stores, $lib, and the config file. The rest of the changes below are the ones that change how the app runs.
Config now lives in Vite
svelte.config.js is gone. Pass the same options to sveltekit() in vite.config.js. Anything that used to sit under kit: is now a top-level plugin option, next to compilerOptions:
import { defineConfig } from 'vite';
import { sveltekit } from '@sveltejs/kit/vite';
import adapter from '@sveltejs/adapter-auto';
export default defineConfig({
plugins: [
sveltekit({
adapter: adapter(),
compilerOptions: {
experimental: {
async: true
}
}
})
]
}); Options that do not belong to SvelteKit are forwarded to vite-plugin-svelte, so things like inspector go on this same object. You no longer wrap them in a vitePlugin key.
Delete these if the migrator leaves them around:
files.lib:$libis not generated any more, see belowvitePluginpreloadStrategy:modulepreloadis always usedprerender.origin: usepaths.origincsrf.checkOrigin: usecsrf.trustedOriginsexperimental.handleRenderingErrorsandexperimental.instrumentation: both happen automatically nowexperimental.tracing: it is a top-leveltracingoption
Three new options:
csrf.trustedOrigins: origins allowed to POST forms at youpaths.origin: the public URL of the app when request headers cannot be trusted (behind a reverse proxy). This also replaces theORIGINenv var onadapter-nodeoutput.linkHeaderPreload: opt back intoLinkheaders for JS/CSS; 3.0 defaults to<link>tags because huge header lists break some hosts
version.pollInterval now defaults to one hour. SvelteKit will check for a new deployment on its own and set updated.current to true. Previously it never polled unless you asked.
Point tsconfig.json at $app/tsconfig instead of ./.svelte-kit/tsconfig.json, and list include / exclude yourself:
{
"extends": "$app/tsconfig",
"include": ["src", "test", "*"],
"exclude": ["src/service-worker"]
} If you have a service worker, give it its own src/service-worker/tsconfig.json that extends $app/tsconfig/service-worker. A service worker is a different TypeScript project; mixing it into the app config gives you the wrong types for fetch events.
$lib is now #lib
SvelteKit no longer invents a $lib alias. You declare #lib yourself with Node subpath imports, which Vite and TypeScript already understand:
{
"imports": {
"#lib": "./src/lib/index.js",
"#lib/*": "./src/lib/*"
}
} Then add the file extension:
import { foo } from '$lib/foo';
import { foo } from '#lib/foo.js'; The # prefix marks an internal alias, not an npm package. You still import from src/lib; only the alias spelling changed.
Modules that were renamed or removed
These imports fail once you bump the version.
$app/stores is gone
Use $app/state. Drop the $ prefix when you read values: it is Svelte 5 state, not a store.
<script>
import { page } from '$app/state';
</script>
<p>current pathname: {page.url.pathname}</p> page.url is now readonly. Copy it before you mutate search params:
const url = new URL(page.url.href);
url.searchParams.set('q', 'svelte'); updated.current flips to true more often than it used to: after a navigation that hits the server, after a remote function call, when the tab becomes visible, and on that new one-hour poll. If you use Vercel skew protection, the navigation and remote-function checks can lie (the old deployment still answers). The poll and the focus check still work.
$app/environment is $app/env
Same values, shorter path. It also works inside a service worker.
The $env/static/* and $env/dynamic/* modules still exist but are deprecated. Prefer $app/env/private and $app/env/public.
$app/paths: base, assets, resolveRoute are gone
import { asset, resolve } from '$app/paths';
const pathname = resolve('/blog/[slug]', { slug });
const file = asset('foo.png'); Route IDs still start with /. Plain pathnames passed to resolve do not: resolve('blog/hello-world'). Asset paths lose the leading slash too: asset('foo.png'), not asset('/foo.png').
$service-worker is gone
Split the old imports:
versionfrom$app/envassets,immutable,prerenderedfrom the new$app/manifest- path helpers from
$app/paths
Service workers are registered as ES modules now.
Navigation and forms
pushState / replaceState for shallow routing are deprecated. Use goto with shallow: true:
import { goto } from '$app/navigation';
goto('/foo', { shallow: true, state });
goto('/bar', { shallow: true, replace: true, state }); goto option names changed:
| SvelteKit 2 | SvelteKit 3 |
|---|---|
invalidateAll | refreshAll |
keepFocus: true and noScroll: true | reset: false |
replaceState | replace |
invalidateAll itself is deprecated in favour of refreshAll. The difference: refreshAll leaves page.state alone, which is what you want with shallow routing. Calling invalidate / invalidateAll during an in-flight navigation also no longer aborts that navigation.
goto now rejects a URL that is not a route in your app. For a real external URL, set window.location.href.
preloadData can come back as an error result (type set to 'error', plus status and error). Handle that branch instead of assuming 'loaded'.
A form with use:enhance that posts to another page now navigates there, the same way a plain HTML form would. Previously the enhanced submit stayed put.
A click on a link to the page you are already on runs refreshAll() instead of doing nothing.
data-sveltekit-preload-data="off" is now "false":
<a href="/slow" data-sveltekit-preload-data="false">Skip preload</a> Cookies, CSRF, and redirects
SvelteKit 3 uses cookie v2. Cookie names must be ASCII. The path option is no longer required: omit it and the cookie is set for /, the whole site. That is what most session cookies wanted anyway, and it is what cookie-based login already does. Pass path only when you want a tighter scope.
cookies.set('session', id, {
httpOnly: true,
secure: !dev,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7
}); CSRF protection is always on. csrf.checkOrigin: false is gone. If a payment provider or an auth callback needs to POST a form at you, list it:
sveltekit({
csrf: {
trustedOrigins: ['https://checkout.stripe.com']
}
}) Cross-origin mutative requests without a Content-Type header are rejected too. Add the header, or add the origin to that list.
redirect() to another site now needs an explicit opt-in. javascript: URLs stay blocked even with external: true:
import { redirect } from '@sveltejs/kit';
redirect(307, 'https://example.com', { external: true }); Pass external as an array of origins if you want an allowlist instead of any http(s) URL.
In development, SvelteKit no longer slaps Access-Control-Allow-Origin: * on every static file. If a local tool needs that, set it on Vite:
export default defineConfig({
server: {
cors: { origin: '*' }
}
}); Errors and hooks
Hooks still run on every request. The Handle type (and the other hook types) now live in @sveltejs/kit/hooks:
import type { Handle } from '@sveltejs/kit/hooks'; handleError now sees every error, including the ones you throw with error(). In SvelteKit 2 it skipped those. You can also return a status from handleError to control the HTTP code the error page renders with.
error() itself takes a string message, then an optional extras object. You can no longer pass the extras as the second argument:
import { error } from '@sveltejs/kit';
error(404, 'Post not found', { code: 'POST_MISSING' }); App.Error always has a status field, so +error.svelte can read the status off the error object.
handleValidationError is gone. Validation failures arrive at handleError with kind: 'validation'. The status and message are safe to send to the client; the raw issues are for your logs.
Errors thrown while rendering go through handleError and then the nearest error boundary. Each +error.svelte is an error boundary automatically. If your client handleError is async, turn on compilerOptions.experimental.async so Svelte can await it.
fail(400, …) from a form action now shows up as HTTP 400 on an enhanced submit. SvelteKit 2 always reported 200. Update any use:enhance callback or test that asserted 200.
json() and text() from @sveltejs/kit are deprecated. Use the platform:
return Response.json({ ok: true });
return new Response('ok'); isHttpError / isRedirect are the replacements for instanceof checks against the old internal classes.
Server-only files
A file is server-only when its name has a server segment, not only when it is named *.server.ts. These are all server-only:
stuff.server.tsstuff.server.test.tsserver.ts
src/lib/server still works, and the same rule now applies to any server/ directory in the project except src/routes and static. Import one of these from a component and the build fails, so secrets and session code stay off the client.
Param matchers are one file
src/params/integer.ts and friends are gone. Declare every matcher in src/params.ts with defineParams from @sveltejs/kit/params. A matcher is either a function that returns the parsed value (or undefined to reject) or a Standard Schema:
import { defineParams } from '@sveltejs/kit/params';
import * as v from 'valibot';
export const params = defineParams({
integer: v.pipe(v.string(), v.toNumber()),
fruit: (param) => {
if (param === 'apple' || param === 'orange') return param;
}
}); Routes still use them the same way: src/routes/items/[id=integer]/+page.svelte.
Adapters
Every first-party adapter now requires SvelteKit 3.
adapter-vercel: theedgeruntime is goneadapter-node: bundles with Rolldown; setpaths.origininstead ofORIGINadapter-cloudflare: needs Wrangler^4.67.0;platform.contextis nowplatform.ctxadapter-netlify: output follows the Netlify Frameworks API; the Netlify CLI must bev17.31.0or later
A +server.js handler that returns 204 (or any empty 2xx) now has an empty body, the way HTTP says it should. If you were reading a SvelteKit envelope off those responses, stop.
If you write adapters: builder.config.kit is gone (config is top-level), and builder.createEntries is gone. Call writeClient, writeServer and writePrerendered yourself.
Optional: remote functions and tracing
Remote functions are still experimental. Opt in with both flags:
sveltekit({
compilerOptions: {
experimental: { async: true }
},
experimental: {
remoteFunctions: true
}
}) A remote segment in a filename (stuff.remote.ts, remote.ts) marks the module. Those files error if the flag is off.
Inside a remote query you cannot read event.url, event.params or event.route. Pass what you need as arguments. Form fields must use field.as(...); a raw name="message" is rejected.
Server instrumentation runs automatically when src/instrumentation.server.js exists. Turn on OpenTelemetry spans with tracing.server set to true on the plugin. That has a cost; leave it off until you need it.
A short checklist
After npx sv migrate sveltekit-3:
- Confirm Node, TypeScript, Svelte, Vite and
vite-plugin-sveltemeet the minimums. - Delete
svelte.config.jsand read the newvite.config.js. - Add the
#libentries topackage.jsonand fix remaining$lib/ missing-extension imports. - Grep for
$app/stores,$app/environment,$service-worker,pushState,replaceState,invalidateAll,json(,text(,csrf.checkOrigin,redirect(, anddata-sveltekit-preload-data="off". - Point
tsconfig.jsonat$app/tsconfig. - Hit login, logout, any cross-origin form, and any
gototo an external URL. Those are the places the new security defaults show up.
SvelteKit is still a full-stack framework: server hooks, form actions and load functions work the way they did, with a few stricter names.