SvelteKit Remote Functions β€” No API Routes, No Problem

Published on Apr 12, 2026

SvelteKit Remote Functions β€” No API Routes, No Problem

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.

Note

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

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:

PrimitivePurposeTriggerReturns
queryRead dynamic dataRender / reactivityCached promise
formWrite via <form>Form submissionSpreadable form object
commandWrite via codeEvent handler / logicPromise
prerenderRead static dataBuild timeCached, 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>
Warning

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>
Note

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 any or manual type annotation.
  • Progressive enhancement: Forms work without JavaScript. The server generates method and action attributes for native POST submissions.
  • No boilerplate: Zero +server.ts files. Zero manual fetch calls. 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.