This week I moved three bits of svelteconsulting.dev onto SvelteKit remote functions, one of each kind I needed. The short version:
queryfor data a component depends on. If the query takes no arguments and never changes, a plain top-levelawaitin the component is all you need, and SSR serialises the result into the page.prerenderfor data that only changes when you deploy. It runs once at build time, so visitors never touch the upstream API.commandfor fire-and-forget writes from the client, like analytics events.
None of the three were right first time. The search index took four commits. Here’s what I did, what went wrong and what the code looks like now.
The config
Remote functions and async Svelte are both still experimental, so
they’re opt-in. This site is on SvelteKit 3 (pre-release), where the
Kit config lives in the sveltekit() Vite plugin:
1// vite.config.ts
2sveltekit({
3 adapter: adapter(),
4 compilerOptions: { experimental: { async: true } },
5 experimental: { remoteFunctions: true },
6 extensions: ['.svelte', '.md'],
7 // ...
8}),remoteFunctions lets you export query, prerender, command and form from .remote.ts files. compilerOptions.experimental.async lets you use await at the top level of a component’s script and
inside markup. You want both, because the nicest way to consume a
remote function is to await it.
query: the Ctrl+K search index
The site has a command palette on Ctrl/Cmd+K that searches pages, services and blog posts. The index is small: titles, one-line descriptions and tags. Post bodies never reach the client.
Attempt one: fetch in an effect
I ported the palette from another site, and it came with a /api/search.json endpoint and a fetch inside $effect:
1<script lang="ts">
2 let index = $state<SearchIndex | null>(null);
3
4 // the service and post index loads the first time the palette opens
5 $effect(() => {
6 if (!command_palette.open || index) return;
7 fetch('/api/search.json')
8 .then((response) => response.json())
9 .then((data: SearchIndex) => (index = data))
10 .catch(() => {});
11 });
12</script>It worked when you opened the palette, waited, then typed. If you pressed Ctrl+K and typed straight away, the post results mounted after you’d started typing with nothing selected, and Enter did nothing.
My first fix was to prefetch the index with requestIdleCallback in a
second $effect, with a loading flag to stop double fetches. That
made the race rarer but didn’t remove it, and it doubled down on the
real problem. $effect is for syncing with things outside the app:
the DOM, a third-party library, a timer. Loading data the component
needs to render isn’t that. Two effects and a flag to fetch one JSON
file was the code telling me so.
Attempt two: a query read through .current
The remote function itself is tiny:
1// src/lib/search.remote.ts
2import { query } from '$app/server';
3import { get_search_index } from './search.js';
4
5/** Services and posts for the command palette */
6export const search_index = query(async () => get_search_index());get_search_index() builds the services and posts lists on the
server. That meant I could delete the /api/search.json route and its
test.
In the component I called it once and read .current:
1<script lang="ts">
2 import { search_index } from '#lib/search.remote.js';
3
4 const index = search_index();
5</script>
6
7{#if index.current?.posts.length}
8 <!-- ... -->
9{/if}This is a lot tidier than the effects, and the query is cached. But it
still raced. .current is undefined until the query resolves, and
the query only starts once the component runs in the browser, after
hydration. Someone quick on the keyboard still beat it.
Attempt three: await it in a $derived
The fix for the race is to resolve the query during SSR. If the component awaits the query at the top level, the server waits for it, renders with the data and serialises the result into the page. On hydration the client reuses that result instead of fetching again:
1<script lang="ts">
2 const index = $derived(await search_index());
3</script>
4
5{#if index.posts.length}
6 <!-- ... -->
7{/if}The results now exist the moment the page hydrates, so the race was
gone. Svelte then gave me an await_waterfall warning. The palette
content is inside a dialog that’s closed on load, so the async $derived was only read once the dialog opened, and Svelte flags that
pattern as a likely waterfall.
Attempt four: a plain top-level await
I reached for $derived out of habit. A $derived exists to
recompute when its dependencies change, and this query has no
arguments and never changes during the life of the page. There’s
nothing for it to react to:
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. No
6 // args and never changes, so it doesn't need to be reactive
7 const index = await search_index();
8</script>That’s the version in production. No effects, no loading state, no .current null checks, no race and no warning.
My rule of thumb from this: if a query takes arguments that come from
state, $derived(await ...) is the right shape because it re-runs
when they change. If it takes none, a plain const does the job.
Testing a component that awaits a remote function
The palette has browser tests with vitest-browser-svelte. The first
version stubbed fetch globally. Once the index came from a remote
module, I mocked that module instead. The mock only has to match how
the component consumes it, so with the component awaiting the query,
an async function returning a fixture is enough:
1// The remote query runs on the server; the component awaits it, so a
2// resolved promise stands in for it
3vi.mock('#lib/search.remote.js', () => ({
4 search_index: async () => index,
5}));While the component read .current, the mock was () => ({ current: index }). Each refactor changed the mock, which is
a fair reminder that it mirrors the component’s usage, not the real
query API.
prerender: the MCP repos list on /about
The about page lists my MCP server repos from GitHub with stars and
forks. It started as a query that called the GitHub API, and the
component used an {#await} block with a “Failed to load” branch.
The GitHub call was unauthenticated and ran on every visit. The unauthenticated limit is 60 requests an hour per IP, and every request came from the server’s IP. It doesn’t take much traffic to use that up, and then GitHub returned 403s and visitors saw “Failed to load”.
I could have added a token or a cache, but star counts on an about
page don’t need to be live. They only need to be as fresh as the last
deploy, which is exactly what prerender is for:
1// src/routes/about/github-repos.remote.ts
2import { prerender } from '$app/server';
3
4/**
5 * Runs once at build time, so visitors never hit the GitHub API
6 * (unauthenticated requests are capped at 60 an hour per IP). A
7 * failed fetch gives an empty list rather than failing the deploy.
8 */
9export const get_mcp_repos = prerender(
10 async (): Promise<McpRepo[]> => {
11 try {
12 const response = await fetch(
13 'https://api.github.com/users/spences10/repos?per_page=100&sort=updated',
14 {
15 headers: {
16 Accept: 'application/vnd.github+json',
17 'X-GitHub-Api-Version': '2022-11-28',
18 },
19 },
20 );
21 if (!response.ok) {
22 console.warn(`[about] GitHub API ${response.status}`);
23 return [];
24 }
25
26 const repos: GitHubRepo[] = await response.json();
27
28 return repos
29 .filter((repo) => repo.name.startsWith('mcp-'))
30 .sort((a, b) => b.stargazers_count - a.stargazers_count)
31 .slice(0, 10)
32 .map((repo) => ({
33 name: repo.name,
34 url: repo.html_url,
35 description: repo.description,
36 stars: repo.stargazers_count,
37 forks: repo.forks_count,
38 }));
39 } catch (error) {
40 console.warn('[about] GitHub API unreachable', error);
41 return [];
42 }
43 },
44);The try/catch matters more here than it would in a query. A
prerender function runs during the build, so a thrown error fails the
deploy. I don’t want a GitHub outage or a rate limit on the build
machine to stop a release, so any failure logs a warning and returns
an empty list.
The component hides the box when that list is empty, instead of showing an error:
1<script lang="ts">
2 import { get_mcp_repos } from './github-repos.remote';
3
4 // Prerendered at build time, so this resolves from static data
5 const repos = await get_mcp_repos();
6</script>
7
8{#if repos.length}
9 <div class="not-prose max-h-64 overflow-y-auto rounded-lg border">
10 <ul class="divide-y">
11 {#each repos as repo (repo.url)}
12 <!-- name, description, stars, forks -->
13 {/each}
14 </ul>
15 </div>
16{/if}Same shape as the search index: a top-level await, no loading or
error branches. While I was in there I also trimmed the returned data
to the five fields the component uses. The old query returned topics,
language, licence and last updated date, and nothing read them.
command: analytics tracking
The site has its own analytics in SQLite alongside Fathom. Full page
loads go through the server’s handle hook, so they’re recorded
there. Client-side navigations never reach handle, and neither do
clicks on email links, the contact CTA or outbound links, so those
need the browser to tell the server. That’s a command: a write,
called from the client, with no data coming back:
1// src/lib/analytics/track.remote.ts
2const Path = v.pipe(v.string(), v.startsWith('/'), v.maxLength(512));
3
4/** Client-side navigations, which never reach the page handle */
5export const track_view = command(
6 v.object({ path: Path }),
7 ({ path }) => {
8 if (should_skip_path(path)) return;
9
10 const { request } = getRequestEvent();
11 const user_agent = request.headers.get('user-agent');
12 queue_page_view({
13 visitor_hash: visitor_hash(request),
14 path,
15 referrer: null,
16 user_agent,
17 ip: anonymise_ip(get_client_ip(request)),
18 country: request.headers.get('cf-ipcountry'),
19 ...parse_user_agent(user_agent),
20 created_at: Date.now(),
21 });
22 },
23);Two things I like about this. The Valibot schema validates the
argument on the server before the handler runs, so a hand-crafted
request with a 10 KB path gets rejected rather than written to the
database. And getRequestEvent() gives the handler the real request,
so the visitor hash, IP and user agent come from headers the server
can see, not from anything the client claims.
track_click follows the same pattern with an event_name, path and optional context.
The wiring is in the root layout:
1<script lang="ts">
2 import {
3 track_click,
4 track_view,
5 } from '#lib/analytics/track.remote.js';
6
7 // The server records full page loads; count in-app navigations
8 afterNavigate(({ type, to }) => {
9 if (type === 'enter' || !to) return;
10 track_view({ path: to.url.pathname }).catch(() => {});
11 });
12
13 function track_link(event: MouseEvent) {
14 const link = (event.target as Element | null)?.closest('a');
15 const href = link?.getAttribute('href');
16 if (!href) return;
17
18 const tracked = classify_link(
19 href,
20 location.origin,
21 page.url.pathname,
22 );
23 if (tracked) {
24 track_click({ ...tracked, path: page.url.pathname }).catch(
25 () => {},
26 );
27 }
28 }
29</script>
30
31<svelte:document onclick={track_link} />type === 'enter' is the initial page load, which the server already
counted, so skipping it avoids double counting. Each call has a .catch(() => {}) because a failed analytics write must never break
navigation or throw an unhandled rejection in someone’s console.
The 403 in local e2e
The Playwright suite builds the site and runs node build/index.js,
then hits it over http://localhost. Every command call came back as
a 403, “Cross-site remote requests are forbidden”.
In production builds, SvelteKit checks that any remote request that
isn’t a GET comes from the same origin as the server. Commands are POSTs, so they’re checked. adapter-node works out the server’s own
protocol from the header named in PROTOCOL_HEADER, and falls back to https when there isn’t one. So the server thought it was https://localhost, the browser sent Origin: http://localhost, and
the two didn’t match.
The dev server doesn’t run this check, so it only shows up against a
real build. The fix is test-only config in playwright.config.ts:
tell the server which header to trust, and have the test browser send
it.
1use: {
2 baseURL: base_url ?? `http://localhost:${port}`,
3 // adapter-node assumes https, so remote function calls from
4 // http://localhost fail its origin check without this
5 extraHTTPHeaders: { 'x-forwarded-proto': 'http' },
6},
7webServer: {
8 command: 'pnpm build && node build/index.js',
9 env: {
10 PORT: String(port),
11 PROTOCOL_HEADER: 'x-forwarded-proto',
12 // ...
13 },
14},In production the site is served over HTTPS, so the https default
matches and the test-only headers aren’t needed.
What I’d tell a team adopting remote functions
- Pick the function by how often the data changes. Per deploy is
prerender. Per request isquery. A write from the client iscommand. - Await queries at the top level of the component so SSR resolves them. That’s what fixed the palette race, not caching.
- Only wrap the await in
$derivedwhen the arguments are reactive. - Wrap anything that runs at build time in a
try/catchwith a sensible fallback, unless you want an upstream outage to block your deploys. - Run your e2e suite against a production build, not the dev server. The remote origin check only runs there.
If your team is moving a SvelteKit app from load functions and API routes to remote functions, or wants a second pair of eyes on the async Svelte patterns, get in touch.