The SvelteKit remote functions API —
query, query.batch, query.live, command, form — for apps without a
backend. Your functions run locally in the browser (IndexedDB, SQLite-WASM,
localStorage, in-memory, …) but you keep the exact same ergonomics: reactive, cached,
awaitable queries; commands with optimistic updates; progressive form handling with
typed fields and validation.
Works in any Svelte 5 app — plain Vite SPA or SvelteKit (e.g. in SPA mode). Runes-only, fully typed, no dependencies beyond Svelte itself.
The implementation is a direct port of SvelteKit's client-side remote-functions runtime with the HTTP transport removed. Behavior intentionally matches kit; every divergence is documented in DIFFERENCES.md.
npm install svelte-local-queryRequires svelte@^5.29 and a bundler that understands the svelte export condition
(Vite with @sveltejs/vite-plugin-svelte, or SvelteKit).
Declare your functions in any module — a .ts, .svelte.ts or <script module> block
(module level, so the cache is shared across components):
// data.ts
import * as v from 'valibot'; // any Standard Schema library (zod, valibot, arktype...)
import { command, form, query } from 'svelte-local-query';
import { db } from './db'; // your local storage layer
export const getPosts = query(async () => {
return db.posts.orderBy('date').toArray();
});
export const getPost = query(v.string(), async (id) => {
return db.posts.get(id);
});
export const addLike = command(v.string(), async (id) => {
await db.posts.update(id, { likes: (await db.posts.get(id)).likes + 1 });
});
export const createPost = form(
v.object({
title: v.pipe(v.string(), v.nonEmpty('Please enter a title')),
content: v.string()
}),
async (data) => {
const id = crypto.randomUUID();
await db.posts.add({ id, ...data });
return { id };
}
);Use them in components:
<script>
import { getPosts, addLike, createPost } from './data';
const posts = getPosts();
</script>
<!-- reactive access -->
{#if posts.error}
<p>Something went wrong</p>
{:else if !posts.ready}
<p>Loading...</p>
{:else}
{#each posts.current as post}
<article>
<h2>{post.title}</h2>
<button onclick={() => addLike(post.id)}>❤️ {post.likes}</button>
</article>
{/each}
{/if}
<!-- or await-based, with <svelte:boundary> -->
<!-- {#each await getPosts() as post} ... {/each} -->
<form {...createPost}>
<label>
Title
<input {...createPost.fields.title.as('text')} />
{#each createPost.fields.title.issues() ?? [] as issue}
<span class="error">{issue.message}</span>
{/each}
</label>
<textarea {...createPost.fields.content.as('text')}></textarea>
<button disabled={!!createPost.pending}>Publish</button>
</form>Queries with the same argument share a single cached instance, no matter where they are called from. When nothing references a query anymore, its cache entry is released automatically.
The API mirrors SvelteKit remote functions — the official docs apply almost verbatim. A condensed tour:
const getTodos = query(async () => [...]); // no argument
const search = query((f: { filter?: string }) => {...}); // TS-inferred argument
const getTodo = query(v.string(), async (id) => {...}); // validated argument
const legacy = query('unchecked', async (filters: Filters) => {...}); // typed, unvalidatedSince everything runs locally (no trust boundary), the argument type can be inferred
straight from the handler's parameter — no schema, no 'unchecked' (this is a
local-only extension
to the kit API). Use a schema whenever the value comes from outside your code.
Calling getTodo(id) returns a LocalQuery<T>:
current— latest value (undefineduntilready)ready/loading/error— reactive state (loadingis alsotrueduring refreshes)- awaitable:
await getTodo(id)resolves to the value refresh()— re-run the functionset(value)— replace the value without re-runningwithOverride(fn)— optimistic override for use with.updates(...)
Solves n+1 against your local store: calls within the same macrotask are collected, the handler receives all arguments at once and returns a resolver to fan results back out.
const getUser = query.batch(v.string(), async (ids) => {
const users = await db.users.bulkGet(ids);
const lookup = new Map(users.map((u) => [u.id, u]));
return (id) => lookup.get(id);
});The handler returns an AsyncIterable (usually an async generator); every yielded value
becomes current. Back it with whatever emits changes — BroadcastChannel, storage
events, IndexedDB observers, timers:
const onlineUsers = query.live(async function* () {
const channel = new BroadcastChannel('presence');
try {
yield await currentUsers();
while (true) {
await new Promise((resolve) => channel.addEventListener('message', resolve, { once: true }));
yield await currentUsers();
}
} finally {
channel.close();
}
});The instance additionally exposes connected, done, reconnect() and is
async-iterable itself (for await (const users of onlineUsers())).
const addTodo = command((text: string) => db.todos.add({ text })); // TS-inferred argument
const addSafe = command(v.string(), async (text) => {
// schema-validated
await db.todos.add({ text });
});addTodo.pending— reactive count of in-flight executions.- Refresh what changed, either inside the handler (
getTodos().refresh()) or from the call site with single-flight semantics — the awaited promise resolves only after the refreshed queries have re-run:
const todos = getTodos();
await addTodo(text).updates(
todos.withOverride((current) => [...current, { text }]) // optimistic
);.updates(...) accepts query functions (refresh all active instances), query instances,
and withOverride releases.
const updateTodo = form(v.object({ id: v.string(), text: v.string() }), async (data, issue) => {
if (await isDuplicate(data.text)) invalid(issue.text('Already exists'));
await db.todos.put(data);
});- Spread onto a
<form>:<form {...updateTodo}>. fields— typed accessors:fields.text.as('text')(spreadable input props withname, coercion prefixes,aria-invalid),.value(),.set(),.issues(),fields.allIssues(). Nested paths (fields.user.emails[0]) work.- Schema failures skip the handler and populate
issues();invalid()+ theissueproxy create issues imperatively. result,pending,submitted,element, programmaticsubmit(),validate({ includeUntouched, preflightOnly }),preflight(schema).for(key)creates keyed instances for forms in a loop (updateTodo.for(todo.id)), injecting the key asdata.id.enhance(callback)customizes submission; combine with.updates(...)for optimistic updates:
<form
{...updateTodo.enhance(async (form) => {
await form.submit().updates(todos.withOverride((t) => optimistically(t)));
form.element.reset();
})}
>By default a successful submission refreshes all active queries (the local
equivalent of kit's invalidateAll()); taking control via .updates(...) or by
refreshing/setting queries inside the handler disables that.
import { init } from 'svelte-local-query';
import { goto } from '$app/navigation'; // or your router
init({
redirect: (location) => goto(location), // handles redirect(...) from query/form handlers
onerror: (error) => showToast(error) // form submission errors (default: rethrow)
});redirect(location), isRedirect, Redirect, invalid(...issues),
isValidationError, ValidationError, and the full set of types:
LocalQuery, LocalQueryFunction, LocalCommand, LocalForm, LocalLiveQuery,
LocalResource, LocalFormIssue, … (structural ports of kit's Remote* types).
Everything transport-related is gone, and a handful of behaviors necessarily change
(no prerender, no non-JS form fallback, JSON cache keys, direct validation errors,
pluggable redirects, …). The complete annotated list lives in
DIFFERENCES.md.
npm test # vitest suite (jsdom; --expose-gc for cache-eviction tests)
npm run test:e2e # Playwright suite driving the playground in Chromium
npm run check # svelte-check, strict TS
npm run build # svelte-package + publint
npm run dev # vite playground (the page the e2e suite drives)The architecture and most of the runtime are ported from SvelteKit's remote-functions client (MIT License, © the Svelte contributors). This project just removes the network.
MIT