SvelteKit remote functions let you declare server-only functions in a .remote.ts file and call them straight from a component, with types and schema validation carried across the wire. They cover most of what +page.server.ts load functions, form actions and +server.ts endpoints do today. As of September 2026 they are still experimental, and still behind a config flag.
That flag is the whole decision. The API is genuinely good. The docs are also blunt about what you are signing up for: “This feature is currently experimental, meaning it is likely to contain bugs and is subject to change without notice.” The SvelteKit 3 release candidate, published on 13 August 2026, keeps them exactly where they were. “For now, though, they remain behind an experimental flag as we iron out the last few kinks.”
Turning them on takes two flags, not one, because the query API leans on Svelte’s async compiler mode.
// svelte.config.js
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
experimental: {
remoteFunctions: true
}
},
compilerOptions: {
experimental: {
async: true
}
}
};
export default config;
What do remote functions replace?
The old shape of a SvelteKit page is a load function in +page.server.ts that returns data for one route, plus a set of named form actions on the same file, plus a +server.ts endpoint for anything the client needs to call later. Data is tied to routes, and sharing it across routes means either duplicating the load or hoisting it into a layout.
Remote functions untie the two. A file named posts.remote.ts can live anywhere under src except src/lib/server, and anything it exports becomes callable from the client. SvelteKit rewrites the call into an HTTP request and keeps the types.
// src/lib/posts.remote.ts
import * as v from 'valibot';
import { query, form } from '$app/server';
import { redirect } from '@sveltejs/kit';
import * as db from '$lib/server/database';
export const getPosts = query(async () => {
return await db.listPosts();
});
export const getPost = query(v.string(), async (slug) => {
return await db.getPost(slug);
});
export const createPost = form(
v.object({
title: v.pipe(v.string(), v.nonEmpty('Give it a title')),
content: v.pipe(v.string(), v.nonEmpty())
}),
async ({ title, content }) => {
const slug = await db.createPost(title, content);
void getPosts().refresh();
redirect(303, `/blog/${slug}`);
}
);
Any argument that crosses the network has to go through a Standard Schema validator, so Valibot, Zod and Arktype all work. That requirement is not decoration. A remote function is a public HTTP endpoint, and the schema is the only thing standing between a caller and your database call. If you have not met the spec before, we covered why one validation interface beats per-library adapters separately.
The component side is where the ergonomics land:
<script lang="ts">
import { getPosts, createPost } from '$lib/posts.remote';
</script>
<form {...createPost}>
<input {...createPost.fields.title.as('text')} />
{#each createPost.fields.title.issues() ?? [] as issue}
<p class="error">{issue.message}</p>
{/each}
<textarea {...createPost.fields.content.as('text')}></textarea>
<button>Publish</button>
</form>
<ul>
{#each await getPosts() as post}
<li><a href="/blog/{post.slug}">{post.title}</a></li>
{/each}
</ul>
No +page.server.ts, no export const actions, no manual enhance wiring. The form still submits without JavaScript.
Which remote function should you reach for?
There are four exports from $app/server, and query has two variants hanging off it.
Use query for reading dynamic data. It dedupes identical calls on the client, caches per request on the server, and gives you current, error and loading alongside the awaitable value.
Use query.batch when a list renders a component that each fetches one row. The server callback receives the whole array of arguments and returns a lookup function, which collapses the n+1 into one statement.
export const getWeather = query.batch(v.string(), async (cityIds) => {
const rows = await db.sql`SELECT * FROM weather WHERE city_id = ANY(${cityIds})`;
const lookup = new Map(rows.map((row) => [row.city_id, row]));
return (cityId) => lookup.get(cityId);
});
Use query.live for a stream. You write an async generator, and since version 2.63.1 SvelteKit delivers it over server-sent events, with connected and reconnect() on the instance instead of refresh(). If you are weighing that against a socket, the trade-offs between SSE and WebSockets apply here unchanged.
Use form for mutations that belong in a <form>, and command for mutations fired from an event handler. Commands cannot be called during render. Use prerender for build-time data that gets baked into the HTML.
How do you avoid a second round trip after a mutation?
The usual pattern in any framework is mutate, then refetch, which costs two requests. SvelteKit calls the fix single-flight mutations, and it works from either end.
From the server, call .refresh() or .set() on a query inside the handler, as in the createPost example above. The refreshed data rides back on the mutation’s own response. From the client, chain .updates() onto the call and add .withOverride() for an optimistic value that holds until the server answers.
await addLike(item.id).updates(
getLikes(item.id).withOverride((n) => n + 1)
);
One sharp edge: an empty .updates() means “update nothing”, not “update everything”. That changed in 2.59.0 and it is easy to misread.
Are SvelteKit remote functions production ready?
They work, and people are shipping them. The honest risk is churn. Remote functions arrived in @sveltejs/kit 2.27.0 and the changelog since then reads like an API still finding its shape:
- 2.42.0 stopped passing
FormDatato form handlers and started passing a parsed object. - 2.50.0 deleted
buttonProps, replaced byfield.as('submit', value). - 2.56.0 changed cache-key generation by sorting object keys.
- 2.59.0 changed what the server-side
refreshpromise resolves to. - 2.61.0 removed
.run()entirely, becauseawait query()now works in every context.
Every one of those is a silent behaviour change or a compile error in code you already wrote. Latest at the time of writing is 2.70.3, with svelte at 5.57.0.
Two recent fixes are worth knowing about if you are already on this. Version 2.65.2 started sending cache-control: private, no-store on remote function responses, so personalised query results cannot land in a shared cache. The same release stopped collapsing transport failures into a generic 500, which means a 401 thrown from a handle hook now reaches the client as a 401.
What bites people first?
Authorisation. A .remote.ts export is an endpoint whether or not any component calls it, and SvelteKit will not check permissions for you. Validating the argument shape proves the input is a string; it proves nothing about whether this user may read that record. Put the check in the handler.
After that: queries cannot run on a fully prerendered page, prerender functions cannot take client-supplied arguments, unchecked checkboxes and unselected <select> elements are absent from the submitted data so their schema fields need optional(), and field names containing characters that need quoting are not supported.
Should you enable the flag today?
For a greenfield SvelteKit app with one team on it, yes. The productivity gain over load functions plus actions plus endpoints is real, the query.batch story solves a problem most apps hit eventually, and pinning an exact @sveltejs/kit version costs you nothing while you evaluate.
For an existing app with a working +page.server.ts layer, migrate one route and stop. You get to judge the API against your own code without owning a half-finished rewrite when the next breaking change lands.
For anything you hand over to a client team that will not be reading SvelteKit changelogs, wait. The flag is not a formality, and a feature the maintainers describe as removable at any time is a poor foundation for code someone else maintains. Note also that SvelteKit 3 requires Vite 8, so that upgrade arrives on the same schedule, and what breaks in the Rolldown build is worth reading before you plan either.