Search the site
Find pages, services and blog posts
Skip to content
All posts

Upgrading a SvelteKit site to Kit 3, Vite+ and node 24: what actually broke

  • svelte
  • sveltekit
  • vite
  • migration
  • tooling

This week I moved two SvelteKit sites, oestechnology.co.uk and this one, onto SvelteKit 3, adapter-node 6, Vite+, TypeScript 6 and node 24. The code changes were mostly mechanical: $lib became #lib package imports, environment variables moved to $app/env, and svelte.config.js folded into vite.config.ts. What actually broke was around the edges. Remote functions returned 403 locally because adapter-node 6 needs PROTOCOL_HEADER, vp preview hung Playwright’s teardown, and Vitest 5 and TypeScript 7 had to wait.

OES went first on the Saturday, then svelteconsulting.dev on the Sunday, working from a checklist I wrote up as an issue on the repo. Here’s what changed and what I’d tell anyone doing the same.

Where both sites started

svelteconsulting.dev hadn’t had a real change since December 2025. It was on Kit 2.49, Vite 7, vite-plugin-svelte 6, TypeScript 5.9 and adapter-node 5, with ESLint and Prettier for linting and formatting. There were no engines or packageManager fields in package.json, and the nixpacks config deploying it to Coolify pinned node 22 and pnpm 9.

The target was node 24, pnpm 12, SvelteKit 3.0.0-next.27, adapter-node 6.0.0-next.12 and Vite+ 0.3.2 (later bumped to 0.3.3). I did the toolchain first because everything else depends on it.

$lib becomes #lib package imports

Kit 3 drops the $lib alias in favour of Node’s own subpath imports, declared in package.json:

1"imports": {
2	"#lib": "./src/lib/index.js",
3	"#lib/*": "./src/lib/*"
4}

The catch is that these are real module specifiers, so the old extension-less imports don’t resolve any more. Every import has to name the file, .js for TypeScript modules, and barrel folders need their index.js spelled out:

1<script lang="ts">
2	import Footer from '#lib/components/footer.svelte';
3	import * as Tooltip from '#lib/components/ui/tooltip/index.js';
4	import { cn } from '#lib/utils.js';
5</script>

It’s a find-and-replace job with some hand-editing for the barrels. One thing that’s easy to miss: if you use shadcn-svelte, update the aliases in components.json from $lib to #lib too, or the CLI will keep generating components with the old imports.

Environment variables move to $app/env

$app/environment is now $app/env, so dev, browser and building come from there. The $env/static/* and $env/dynamic/* modules are replaced by $app/env/public and $app/env/private, and the variables themselves are declared up front in src/env.ts:

1import { defineEnvVars } from '@sveltejs/kit/env';
2
3const optional = (input: string | undefined) => input;
4
5export const variables = defineEnvVars({
6	PUBLIC_FATHOM_ID: { public: true, static: true, schema: optional },
7	PUBLIC_FATHOM_URL: { public: true, static: true, schema: optional },
8	PUBLIC_TURNSTILE_SITE_KEY: { public: true, static: true },
9	TURNSTILE_SECRET_KEY: { static: true },
10	RESEND_API_KEY: { schema: optional },
11});

Anything that’s allowed to be missing gets a schema that accepts undefined. The call sites get simpler as a result. The Resend client used to reach into env.RESEND_API_KEY from $env/dynamic/private, and now it’s a named import:

1import { dev } from '$app/env';
2import { RESEND_API_KEY } from '$app/env/private';

I like this change. Having one file that lists every variable the app reads makes it much harder to forget one when setting up a new environment.

svelte.config.js goes into vite.config.ts

The Kit config now lives inside the sveltekit() plugin call. The adapter, the experimental flags for remote functions and async Svelte, the .md extension and the preprocessors all moved across, and svelte.config.js was deleted:

1sveltekit({
2	adapter: adapter(),
3	compilerOptions: { experimental: { async: true } },
4	experimental: { remoteFunctions: true },
5	extensions: ['.svelte', '.md'],
6	preprocess: [vitePreprocess(), mdsvex(mdsvex_config), module_script()],
7}),

The old config had a remark plugin inline for post slugs and reading time. Rather than bloat vite.config.ts, I split the mdsvex setup into its own mdsvex.config.js. That file also exports a small preprocessor that rewrites the <script context="module"> mdsvex still emits into <script module>, which gets rid of the Svelte deprecation warning on every post.

tsconfig.json shrinks too. It now extends $app/tsconfig, so all the options that used to be copied in from the starter template can go.

Vite+ replaces ESLint and Prettier

Vite+ gives you one vp command for dev, build, check and test. I removed ESLint, Prettier, their plugins and their config files, and ported my Prettier preferences into an fmt block in vite.config.ts:

1fmt: {
2	useTabs: true,
3	singleQuote: true,
4	printWidth: 70,
5	trailingComma: 'all',
6	proseWrap: 'always',
7	svelte: true,
8	sortTailwindcss: {
9		stylesheet: './src/routes/layout.css',
10	},
11},

The scripts in package.json became vp dev, vp build, vp test, vp check for linting and vp check --fix for formatting. Ten devDependencies and three config files came out of the repo.

Vite+ ships its own Vite core, so the vite dependency points at @voidzero-dev/vite-plus-core and there’s a pnpm override so every package gets the same one. A peer dependency rule covers the fact that Vite+ core reports its own version number rather than Vite 8’s.

TypeScript 6 and svelte-check --tsgo

typescript is pinned at 6.0.3, and @typescript/native is aliased to TypeScript 7 so svelte-check --tsgo can use the native compiler for type checking. The check script ends up as:

1svelte-kit sync && vp check && svelte-check --tsconfig ./tsconfig.json --tsgo

So TypeScript 7 is already doing the heavy lifting. I explain below why the main typescript package stays on 6.

Node 24 runs the scripts directly

A side benefit of node 24: the content scripts in scripts/*.ts used to run through tsx. Node 24 runs TypeScript files natively, so those are now node scripts/sync-content.ts and tsx is gone. I also moved them from better-sqlite3 to the built-in node:sqlite, which removes a native build step from the install.

What broke: remote functions returning 403

This one cost me the most time. The command palette on this site loads its search index through a remote query(). Against the built server on plain http://localhost, including in the Playwright tests, the remote calls came back 403.

The cause is SvelteKit’s CSRF origin check. Reading through adapter-node 6’s handler, the origin comes from paths.origin in the Kit config at build time. If that isn’t set, the adapter works out the protocol from the header named in PROTOCOL_HEADER, and if there’s no such header it assumes https. A test browser on http://localhost sends an http origin, the server thinks it’s serving https, and the origin check rejects the request. Setting an ORIGIN environment variable at runtime, which was my first instinct from adapter-node 5, doesn’t help, because adapter-node 6 doesn’t read one.

In production on Coolify it’s straightforward: set PROTOCOL_HEADER=x-forwarded-proto and HOST_HEADER=x-forwarded-host so the adapter trusts what the proxy sends. For the e2e tests I did the same, and had the test browser send the header too:

1use: {
2	// adapter-node assumes https, so remote function calls from
3	// http://localhost fail its origin check without this
4	extraHTTPHeaders: { 'x-forwarded-proto': 'http' },
5},
6webServer: {
7	command: 'pnpm build && node build/index.js',
8	env: {
9		PROTOCOL_HEADER: 'x-forwarded-proto',
10	},
11},

The analytics endpoint on both sites had the same problem, and the same fix went into OES.

What broke: vp preview hanging Playwright

The Playwright webServer originally ran pnpm build && pnpm preview. With vite-plus 0.3.3, vp preview doesn’t exit when Playwright sends it SIGTERM at the end of the run, so the test process just sits there after all the tests have passed.

The fix was to stop using preview for e2e and run the adapter-node server directly with node build/index.js, which exits cleanly. That’s arguably better anyway, since it’s the same server that runs in production. While I was in there I made reuseExistingServer opt-in through an environment variable, so a run can never quietly test against some other server I’d left running on the port.

What I held back: Vitest 5 and TypeScript 7

Two upgrades were available that I didn’t take.

Vitest stays on 4.1.11 because vite-plus 0.3.3 peers that exact version, and the vitest override in pnpm-workspace.yaml keeps every package on it. Vitest 5 has to wait for a Vite+ release that supports it, and then Vite+ and the whole Vitest family should move together.

The typescript package stays on 6.0.3 because svelte-check needs TypeScript’s JavaScript API from that package, so it can’t simply be swapped for 7. As covered above, TypeScript 7 still does the type checking through @typescript/native and --tsgo, so this is less of a compromise than it sounds.

One more peer dependency rule was needed. runed, which bits-ui pulls in, hasn’t widened its Kit peer range to include Kit 3 yet, so it gets an allowedVersions entry in pnpm-workspace.yaml.

Deploying on Coolify with nixpacks

Both sites deploy through Coolify using nixpacks. The old config installed nodejs_22 and pnpm-9_x from nix. The new one installs nodejs_24, then installs pnpm through npm so I can pin the exact version:

1[phases.setup]
2nixPkgs = ["nodejs_24"]
3aptPkgs = ["curl", "wget"]
4
5[phases.install]
6cmds = [
7	"npm install -g [email protected] && $(npm prefix -g)/bin/pnpm install --frozen-lockfile --config.engine-strict=false",
8]
9
10[phases.build]
11cmds = ["$(npm prefix -g)/bin/pnpm exec vp build"]
12
13[start]
14cmd = "node ./build/index.js"

A few things worth knowing. curl and wget are there because Coolify’s health checks need one of them in the image, and both sites now have an /api/health endpoint for it. The start command calls the built server directly instead of going through a package script. Coolify also sets NIXPACKS_NODE_VERSION to 22 by default, so set it to 24 in the environment. And as above, PROTOCOL_HEADER and HOST_HEADER need to be set there or remote functions will 403 in production just like they did locally.

Would I do it again

Yes. Once the toolchain was in, the rest of the work on both sites (code highlighting, the UI and the new test suites) went on top without fighting the tools. Most of the migration is renaming things.

My advice is to treat it as two jobs. Do the mechanical changes (#lib, $app/env, the config move) and get pnpm check green first. Then run the built server and your e2e suite before you call it done, because that’s where adapter-node 6 and Vite+ showed their differences, not in vp dev.

If you’re planning a Kit 3 migration on a larger codebase and want a hand, get in touch.