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 --tsgoSo 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.