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.

Upgrade in this order

  1. 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.
  2. Bump the runtime and tooling. SvelteKit 3 will not start below these:
  3. 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: $lib is not generated any more, see below
  • vitePlugin
  • preloadStrategy: modulepreload is always used
  • prerender.origin: use paths.origin
  • csrf.checkOrigin: use csrf.trustedOrigins
  • experimental.handleRenderingErrors and experimental.instrumentation: both happen automatically now
  • experimental.tracing: it is a top-level tracing option

Three new options:

  • csrf.trustedOrigins: origins allowed to POST forms at you
  • paths.origin: the public URL of the app when request headers cannot be trusted (behind a reverse proxy). This also replaces the ORIGIN env var on adapter-node
  • output.linkHeaderPreload: opt back into Link headers 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:

  • version from $app/env
  • assets, immutable, prerendered from the new $app/manifest
  • path helpers from $app/paths

Service workers are registered as ES modules now.

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 2SvelteKit 3
invalidateAllrefreshAll
keepFocus: true and noScroll: truereset: false
replaceStatereplace

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.ts
  • stuff.server.test.ts
  • server.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.

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:

  1. Confirm Node, TypeScript, Svelte, Vite and vite-plugin-svelte meet the minimums.
  2. Delete svelte.config.js and read the new vite.config.js.
  3. Add the #lib entries to package.json and fix remaining $lib / missing-extension imports.
  4. Grep for $app/stores, $app/environment, $service-worker, pushState, replaceState, invalidateAll, json(, text(, csrf.checkOrigin, redirect(, and data-sveltekit-preload-data="off".
  5. Point tsconfig.json at $app/tsconfig.
  6. Hit login, logout, any cross-origin form, and any goto to 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.