Every model on Vercel AI Gateway, served by SvelteKit 3 remote functions and async Svelte.
models.remote.ts never ships to the browser. Each export is a query(): import it into a component, call it like a function, and SvelteKit turns the call into a typed fetch. Here it reads the public AI Gateway catalog and memoizes it for 5 minutes.
// models.remote.ts (server only)export const getProviders = query(async () => { const models = await catalog(); // memoized fetch …});
query comes from $app/server. Wrap a server function in it inside a .remote.ts file, and components can import and call the result directly. Each keystroke changes q, so the page calls getModels with new arguments: a new query. The server checks those arguments against the schema (the first argument to query) before your code runs. A shallow goto keeps the URL shareable without navigating.
// models.remote.tsimport { query } from '$app/server'; export const getModels = query( v.object({ q: v.string(), provider: v.string(), page: v.number() }), async ({ q, provider, page }) => { … }); // +page.svelteimport { getModels } from './models.remote';getModels({ q, provider, page: 0 }) // new q → new querygoto(url, { replace: true, shallow: true });
No load function, no loading state. During SSR the server waits for the query, renders the options and embeds the result in the HTML, so hydration reuses it instead of fetching again.
<select> {#each await getProviders() as p (p.id)} <option value={p.id}>{p.id} · {p.count}</option> {/each}</select>
Queries are cached per argument, so first and the list share one request. When arguments change, Svelte keeps the current UI on screen until the new data is ready, instead of flashing a spinner. The red bar sweeping across the list, driven by $effect.pending(), is the only loading state.
{@const first = await getModels({ q, provider, page: 0 })} {first.total} models<ol class:loading={$effect.pending()}>…</ol>
Each page is its own query. A click renders one more await, which becomes a single GET /_app/remote/…/getModels for just those 20 rows. Earlier pages are already cached.
{#each { length: pages }, i} {@const { models } = await getModels({ q, provider, page: i })} {#each models as m}…{/each}{/each} <button onclick={() => pages++}>show more</button>
?model= drives getModel(id), so any panel is a link. An unknown id throws error(404) on the server; the nearest <svelte:boundary> catches it and renders its failed snippet, with a retry button.
{@const m = await getModel(selected)} // models.remote.tsreturn model ?? error(404, `No model called ${id}`);