This site runs on the newest parts of the Svelte stack. As I write
this it’s on Svelte 5.57.1, SvelteKit 3.0.0-next.27, adapter-node 6.0.0-next.12 and Vite+ 0.3.3, with TypeScript 7 doing the type
checking. I moved it over this year partly because I wanted to, and
partly because clients keep asking me whether they should.
This post is the short version of what I tell them. It covers what changed, what I actually use, and where the sharp edges are.
Async Svelte
The biggest change to how I write components is await. Since Svelte
5.36 you can use it in three places that used to be off limits: the
top level of a component’s <script>, inside $derived(...), and in
the markup itself.
It’s still behind a flag. In a SvelteKit 3 project the flag lives in vite.config.ts (more on that below):
1sveltekit({
2 compilerOptions: { experimental: { async: true } },
3 experimental: { remoteFunctions: true },
4});The Svelte docs say the flag will be removed in Svelte 6.
Here’s the command palette on this site. It needs the search index, so it just awaits it:
1<script lang="ts">
2 import { search_index } from '#lib/search.remote.js';
3
4 // Awaited at the top level, so SSR resolves it and serialises it
5 // into the page: results are there the moment Ctrl+K works
6 const index = await search_index();
7</script>Before this I had an $effect that fetched a JSON endpoint when the
palette opened. That meant a race if you typed straight after pressing
Ctrl+K, plus an API route that only existed to feed one component.
Both are gone.
The thing to watch for is waterfalls. If you write two async deriveds in a row, the second one isn’t created until the first has resolved:
1let a = $derived(await one());
2let b = $derived(await two());Svelte warns about this in dev with await_waterfall: “An async
derived … was not read immediately after it resolved”. The fix from
the docs is to create the promises first and await them after:
1let a_promise = $derived(one());
2let b_promise = $derived(two());
3
4let a = $derived(await a_promise);
5let b = $derived(await b_promise);If you’re reviewing a codebase that’s adopted async Svelte, that warning in the browser console is the first thing I’d look for. It’s easy to write, it’s silent in production, and it adds up.
Remote functions
Remote functions arrived in SvelteKit 2.27 and they’re still
experimental. You write a function in a .remote.ts file, call it
from a component, and SvelteKit turns it into a typed network call.
There are four kinds: query, form, command and prerender.
I use three of them on this site.
query reads data. The search index above is one line:
1import { query } from '$app/server';
2import { get_search_index } from './search.js';
3
4export const search_index = query(async () => get_search_index());prerender runs at build time. The About page lists my MCP server
repos from the GitHub API. Unauthenticated GitHub requests are capped
at 60 an hour per IP, and I found that out the hard way when the list
went blank. Switching from query to prerender means it runs once
per deploy and visitors never touch GitHub:
1import { prerender } from '$app/server';
2
3export const get_mcp_repos = prerender(
4 async (): Promise<McpRepo[]> => {
5 try {
6 const response = await fetch(/* GitHub API */);
7 if (!response.ok) return [];
8 // filter, sort, map...
9 } catch {
10 return [];
11 }
12 },
13);A failed fetch returns an empty list rather than failing the deploy.
The component doesn’t know or care that the data is static; it still
does const repos = await get_mcp_repos().
command is for writes. My self-hosted analytics tracks client-side
navigations with one, validated with Valibot:
1export const track_view = command(
2 v.object({ path: Path }),
3 ({ path }) => {
4 const { request } = getRequestEvent();
5 queue_page_view({ path /* ...headers, hashes */ });
6 },
7);Any Standard Schema validator works for the input. You get the validated, typed argument, and bad input never reaches your code.
form is the one I haven’t used here. The contact form predates it
and still posts to a +server.ts route. It takes the same schema
validation as command but wraps a real <form>, so it’s the one to
look at if you’ve got a lot of form actions today.
SvelteKit 3
SvelteKit 3 is still on next tags, but I’ve got it in production on
this site and on scottspence.com. These are the changes that touched
every file.
Config lives in vite.config.ts
svelte.config.js is gone. Kit’s own source says so: “Prior to
SvelteKit 3, config lived in a svelte.config.js file, which is no
longer supported.” Everything moves into the sveltekit() plugin
call. Kit 2.62 added support for this, so you can make the move before
you upgrade.
One gotcha from this site: Kit validates your environment variables
when the config loads. In Vitest that happens before test.env is
applied, so my vite.config.ts now seeds process.env itself when VITEST is set.
#lib replaces $lib
$lib has been removed. If you import from it, the build tells you to
use #lib, which is a standard Node subpath import declared in package.json:
1"imports": {
2 "#lib": "./src/lib/index.js",
3 "#lib/*": "./src/lib/*"
4}Imports become #lib/components/ui/button/index.js. If you have a
large codebase and want to keep $lib, the error message tells you
how to add it back as an alias. I’d do the find-and-replace instead.
It’s mechanical and it’s done in an afternoon.
$app/env and defineEnvVars
$app/environment is deprecated in favour of $app/env, which
exports the same browser, dev, building and version. The
bigger change is environment variables. You now declare them in src/env.ts:
1import { defineEnvVars } from '@sveltejs/kit/env';
2
3const optional = (input: string | undefined) => input;
4
5export const variables = defineEnvVars({
6 PUBLIC_TURNSTILE_SITE_KEY: { public: true, static: true },
7 TURNSTILE_SECRET_KEY: { static: true },
8 RESEND_API_KEY: { schema: optional },
9});Then you import them from $app/env/public or $app/env/private. public decides whether the browser can see it, static decides
whether it’s inlined at build time or read when the app starts, and schema takes a Standard Schema validator or a plain function. With
no schema, the variable has to be set.
This is the change I’d sell to a team lead. Every variable the app uses is now in one typed file, validated at startup. A missing secret fails the boot instead of failing at 2am on the first request that needs it.
adapter-node 6
adapter-node 6 needs Kit 3 (its peer dependency is ^3.0.0-next.0)
and bundles your server with Rolldown.
The change that caught me out was the origin. For years I’ve set an ORIGIN environment variable on Node deploys. In the version I’m on,
the handler takes its origin from Kit’s new paths.origin config
option at build time. With that unset, it works the origin out from
the request headers and assumes https unless you configure a PROTOCOL_HEADER.
Production is https, so it was fine there. Locally I was testing the
built app over plain http, and every remote function call came
back 403. That’s Kit’s CSRF check doing its job: the origin it
expected didn’t match the one the browser sent. If you’re running Kit
3 behind a proxy, set paths.origin or the header options and test a
remote function call before you ship.
TypeScript 7
TypeScript 7 went stable in July. It’s the native port, and the announcement puts it at around 10x faster. The catch for Svelte is that 7.0 ships without a programmatic API. The TypeScript team expect one in 7.1, and until then tools that embed the compiler, the Svelte language tools included, still need TypeScript 6.
So you can’t just install typescript@7 and carry on. What works
today is running both, which the svelte-check README documents:
1pnpm add -D typescript@~6 @typescript/native@npm:typescript@7Then pass --tsgo to svelte-check. My check script is:
1svelte-kit sync && vp check && svelte-check --tsconfig ./tsconfig.json --tsgoThe editor and the rest of the tooling keep using TypeScript 6, and
the type check in CI gets the fast compiler. I’d treat it as opt-in
until 7.1 lands. If --tsgo gives you a result that looks wrong, drop
the flag and compare.
Vite+ as the toolchain
The last change is the one that removed the most config files. Vite+
bundles Vite, Vitest, Oxlint, Oxfmt and Rolldown behind one CLI, vp.
My scripts are now vp dev, vp build, vp test and vp check,
which runs formatting, linting and type checks in one go.
Formatting config sits in the same vite.config.ts as everything
else, under fmt. Prettier and ESLint are gone from this project.
Browser tests import the Playwright provider from vite-plus/test/browser-playwright, and vite itself is aliased to @voidzero-dev/vite-plus-core in package.json.
Running a Kit app, its tests, its linter and its formatter from one config file is a real improvement, especially for onboarding. It’s still a 0.x release, so pin the version and expect some churn.
Should your team upgrade?
Here’s roughly what I tell clients.
- Async Svelte and remote functions: worth adopting on Kit 2 today if you’re comfortable with experimental flags. They change how you structure data loading more than anything else on this list.
- Config in vite.config.ts: do it now on Kit 2.62+. It shrinks the Kit 3 upgrade.
- Kit 3 and adapter-node 6: fine for sites like this one where I
control the whole stack. For a large app with a team behind it, I’d
wait for a stable release, or budget time for the
#lib,$app/envand origin changes. - TypeScript 7: use it through
svelte-check --tsgoin CI if type checking is slow. Don’t swap thetypescriptpackage yet. - Vite+: good for new projects. On an existing one, migrate the tooling separately from the framework upgrade so you’re only debugging one thing at a time.
None of these are hard on their own. Doing all of them at once on a codebase you didn’t write is where teams get stuck. If that’s where you are, get in touch and I’ll help you plan the order.