SvelteKit Remote Functions β No API Routes, No Problem
Published on Apr 12, 2026
What if your SvelteKit app didnβt need API routes at all? No +server.ts files, no manual fetch calls, no separate request/response boilerplate. Just functions β defined on the server, called from the client, fully type-safe.
Thatβs what SvelteKitβs remote functions deliver. BackupHub uses them exclusively, and the result is the cleanest full-stack architecture Iβve built with SvelteKit.
Remote functions are experimental (SvelteKit 2.27+) and require explicit opt-in. The API may change. This post reflects the state as of April 2026.
Table of Contents
- The Traditional Way vs Remote Functions
- The Three Primitives
- query β Reading Data
- form β Writing Data with Progressive Enhancement
- command β Writing Data from Code
- Single-Flight Mutations
- How It All Fits Together
- File Structure
- Validation with Valibot
- Pitfalls and Hard-Won Lessons
- Why This Architecture Works
The Traditional Way vs Remote Functions
In a typical SvelteKit app, writing data to the server means creating API routes or form actions, managing serialization, and wiring up fetch calls. It works β but itβs boilerplate-heavy.
With remote functions, the entire chain collapses. You write a function in a .remote.ts file, import it in your component, and call it. SvelteKit handles the HTTP layer β endpoint generation, serialization, validation β invisibly.
Same result. Half the files. Full type safety from database to template.
The Three Primitives
Remote functions come in four flavours, each designed for a specific data pattern:
| Primitive | Purpose | Trigger | Returns |
|---|---|---|---|
query | Read dynamic data | Render / reactivity | Cached promise |
form | Write via <form> | Form submission | Spreadable form object |
command | Write via code | Event handler / logic | Promise |
prerender | Read static data | Build time | Cached, CDN-ready |
All four are imported from $app/server and defined in files ending with .remote.ts. SvelteKit generates HTTP endpoints for each function automatically β you never see or manage them. BackupHub uses the first three β prerender is for data that only changes between deployments, which doesnβt apply to a live dashboard.
Enabling Remote Functions
Two flags in svelte.config.js:
const config = {
kit: {
experimental: {
remoteFunctions: true
}
},
compilerOptions: {
experimental: {
async: true
}
}
};The async compiler option unlocks Svelteβs await expressions β the ability to await promises directly in your template markup. Combined with remote queries, this eliminates the need for +page.server.ts load functions entirely.
query β Reading Data
A query wraps a server-side function that returns data. On the client, it behaves like a cached, reactive promise.
Hereβs how BackupHub loads the list of backup remotes:
// remotes/data.remote.ts
import { query } from '$app/server';
import { listRemotes } from '$lib/db/index.js';
export const getRemotes = query(async () => {
return listRemotes();
});In the component, just await it inside a {#each} block. The nearest <svelte:boundary> handles loading and error states:
<svelte:boundary>
{#each await getRemotes() as remote (remote.id)}
<Card>
<CardTitle>{remote.name}</CardTitle>
<p>{remote.remote}:{remote.remotePath}</p>
</Card>
{/each}
{#snippet pending()}
<Skeleton />
{/snippet}
{#snippet failed(error, reset)}
<p>{error.message}</p>
<Button onclick={reset}>Retry</Button>
{/snippet}
</svelte:boundary>No onMount, no loading state variables, no +page.server.ts. The data loads when the component renders, deduplicates automatically, and caches while the component is alive.
Query with Arguments
Queries can accept validated arguments. BackupHub uses this for fetching runs per job:
import * as v from 'valibot';
import { query } from '$app/server';
import { listRuns } from '$lib/db/index.js';
export const getJobRuns = query(v.string(), async (jobId) => {
return listRuns(jobId);
});The Valibot schema validates the argument server-side before the handler runs β protecting the endpoint from malformed requests.
form β Writing Data with Progressive Enhancement
The form primitive is the star of the show. It creates an object you spread onto a <form> element. SvelteKit handles:
- Schema validation (client-side preflight + server-side)
- FormData serialization
- Progressive enhancement (works without JS via native POST)
- Field state, validation errors, and submission status
Hereβs BackupHubβs remote creation form:
// remotes/data.remote.ts
import * as v from 'valibot';
import { form } from '$app/server';
import { createRemote } from '$lib/db/index.js';
const RemoteSchema = v.object({
name: v.pipe(v.string(), v.nonEmpty('Name is required')),
remote: v.pipe(v.string(), v.nonEmpty('Remote name is required')),
remotePath: v.pipe(v.string(), v.nonEmpty('Path is required')),
configPath: v.optional(v.string(), '/app/data/rclone.conf'),
extraFlags: v.optional(v.string(), ''),
enabled: coerceBool(true)
});
export const createRemoteForm = form(RemoteSchema, async (data) => {
createRemote({
id: randomUUID(),
...data,
extraFlags: data.extraFlags || null
});
// refresh the query so the UI updates
void getRemotes().refresh();
});Spreading the Form
In the component, spread the form object onto a <form> element. Use .fields to get type-safe field attributes:
<form {...createRemoteForm} class="space-y-4">
<input {...createRemoteForm.fields.name.as('text')} />
{#each createRemoteForm.fields.name.issues() as issue}
<p class="text-destructive">{issue.message}</p>
{/each}
<input {...createRemoteForm.fields.remote.as('text')} />
<input {...createRemoteForm.fields.remotePath.as('text')} />
<button type="submit">Save</button>
</form>What .as('text') returns: a name, type, value, and aria-invalid attribute bundle. The name is auto-generated to match the schema key. Validation errors populate via .issues() β no manual error state needed.
The .enhance() Callback
For dialogs or custom post-submission logic, use .enhance() to hook into the submission lifecycle:
<form {...createRemoteForm.enhance(async ({ submit }) => {
await submit();
// only runs after server responds
if (!createRemoteForm.fields.name.issues()?.length) {
dialogOpen = false;
}
})}
class="space-y-4"
>
<!-- fields -->
</form>Never add a separate onsubmit handler alongside the spread. The form object uses a Svelte 5 attachment to intercept submission. A competing onsubmit races with it β the dialog closes before the POST completes, and nothing gets saved.
The .for() Pattern β Multiple Form Instances
When editing items in a list, each row needs its own isolated form state. Without isolation, all rows share one form instance β reopening an edit dialog shows stale values from the previous item.
.for(id) solves this:
{#each await getRemotes() as remote (remote.id)}
{@const edit = updateRemoteForm.for(remote.id)}
<form {...edit.enhance(async ({ submit }) => {
await submit();
if (!edit.fields.name.issues()?.length) {
editOpen = false;
}
})}
>
<input {...edit.fields.id.as('hidden', remote.id)} />
<input {...edit.fields.name.as('text', remote.name)} />
<button type="submit">Save</button>
</form>
{/each}The second argument to .as('text', remote.name) populates the field with the current value β essential for edit forms.
command β Writing Data from Code
command is for mutations that arenβt tied to a <form> element. Think: delete buttons, toggles, actions triggered from event handlers.
// remotes/data.remote.ts
import * as v from 'valibot';
import { command } from '$app/server';
import { deleteRemote } from '$lib/db/index.js';
export const deleteRemoteCmd = command(v.string(), async (id) => {
deleteRemote(id);
void getRemotes().refresh();
});
export const toggleRemoteCmd = command(
v.object({ id: v.string(), enabled: v.boolean() }),
async ({ id, enabled }) => {
updateRemote(id, { enabled });
void getRemotes().refresh();
}
);Called directly from event handlers β no fetch, no endpoint:
<Switch
checked={remote.enabled}
onCheckedChange={(v) => toggleRemoteCmd({
id: remote.id, enabled: v
})}
/>
<Button onclick={() => deleteRemoteCmd(remote.id)}>
Delete
</Button>Single-Flight Mutations
Notice the void getRemotes().refresh() call inside every form and command handler? Thatβs SvelteKitβs single-flight mutation pattern.
When you call .refresh() on a query inside a mutation handler, SvelteKit bundles the refreshed data into the mutationβs HTTP response. One round-trip β mutation plus fresh data β instead of two.
Without this, the client would need to fire a separate GET after the mutation completes. With single-flight, BackupHubβs forms feel instant β submit, data updates, dialog closes, all in one request.
How It All Fits Together
Hereβs the complete data flow for BackupHubβs remotes page β from database to rendered card grid:
Every mutation refreshes getRemotes() on the server, and the fresh data rides back with the response. The {#each await getRemotes()} block re-renders automatically β no manual state management.
File Structure
Remote function files live alongside their route, following a data.remote.ts convention:
sk/src/routes/
βββ remotes/
β βββ +page.svelte UI component
β βββ data.remote.ts query + form + command
βββ machines/
β βββ +page.svelte
β βββ data.remote.ts
βββ jobs/
β βββ +page.svelte
β βββ data.remote.ts
βββ settings/
βββ +page.svelte
βββ data.remote.ts
Each data.remote.ts is a self-contained API surface for its route. No +server.ts, no +page.server.ts, no /api/ directory. The entire backend interface is co-located with the page that uses it.
Validation with Valibot
Every remote function that accepts input uses Valibot for schema validation. SvelteKit supports any Standard Schema library (Zod, Valibot, ArkType), but Valibotβs tree-shakeable design keeps the bundle small.
A pattern that comes up frequently in BackupHub: coercing HTML checkbox values. Checkboxes send "on" when checked and are absent when unchecked β not booleans:
const coerceBool = (defaultVal: boolean) =>
v.pipe(
v.optional(v.string(), defaultVal ? 'on' : ''),
v.transform((val) => val === 'on')
);
const MachineSchema = v.object({
name: v.pipe(v.string(), v.nonEmpty('Name is required')),
host: v.pipe(v.string(), v.nonEmpty('Host is required')),
wolEnabled: coerceBool(true),
shutdownAfter: coerceBool(false),
enabled: coerceBool(true)
});This keeps the form using native <input type="checkbox"> elements while the handler receives clean booleans.
Pitfalls and Hard-Won Lessons
Building BackupHub with remote functions taught some lessons the docs donβt emphasize enough:
Donβt Mix onsubmit with Form Spreads
The form spread injects a Svelte 5 attachment (not an onsubmit prop) that intercepts form submission via the DOM. Adding your own onsubmit creates a race condition:
<!-- WRONG β onsubmit fires before the async POST completes -->
<form {...updateForm} onsubmit={() => { dialogOpen = false }}>Always close dialogs inside .enhance(), after await submit().
Donβt Spread .as() onto shadcn Components
shadcn-svelte components like <Textarea> use value = $bindable() internally. The value returned by .as() is created with Object.defineProperties as non-writable β this conflicts with the componentβs binding:
<!-- WRONG β silently breaks form submission -->
<Textarea {...form.fields.notes.as('text', current)} />
<!-- CORRECT β use native elements with .as() -->
<textarea {...form.fields.notes.as('text', current)}></textarea>This applies to any bits-ui / shadcn-svelte component that wraps a native input with $bindable(). Stick with native <input>, <textarea>, and <select> elements when spreading .as().
Always Use .for() in Lists
Without .for(id), all rows in an {#each} share one form instance. Opening an edit dialog for item B still shows values from item A:
<!-- WRONG β singleton form, stale data on reopen -->
<form {...updateForm}>
<input {...updateForm.fields.name.as('text', item.name)} />
</form>
<!-- CORRECT β isolated per item -->
{@const edit = updateForm.for(item.id)}
<form {...edit}>
<input {...edit.fields.name.as('text', item.name)} />
</form>Why This Architecture Works
For a self-hosted app like BackupHub β a single Docker container running on Unraid β remote functions are the perfect fit:
- Co-location: Server logic lives next to the UI that uses it. One file to understand, one file to debug.
- Type safety: The Valibot schema is the single source of truth. TypeScript flows from schema β handler β component β template without a single
anyor manual type annotation. - Progressive enhancement: Forms work without JavaScript. The server generates
methodandactionattributes for native POST submissions. - No boilerplate: Zero
+server.tsfiles. Zero manualfetchcalls. Zero JSON parse/stringify. - Single-flight mutations: Mutations and data refreshes happen in one HTTP round-trip, keeping the UI snappy on a LAN dashboard.
For a project that manages backup jobs, machines, remotes, and settings β each with CRUD operations β remote functions eliminated an entire category of code that would otherwise just be plumbing.
This is the first post in the BackupHub series. BackupHub is a self-hosted backup orchestration dashboard for Unraid β managing rsync and rclone jobs, scheduling, Wake-on-LAN, and Discord notifications from a single Docker container.