←All posts

SvelteKit 3 Remote Functions: When the API Layer Disappears

SvelteKit 3 is in release candidate, and its most consequential feature is still behind an experimental flag. Remote functions let you call a server function directly from a component — which deletes a whole layer of boilerplate and moves your security boundary somewhere new.

SvelteKit 3 Remote Functions: When the API Layer Disappears

The SvelteKit 3 release candidate landed on 13 August with a subtitle the team clearly enjoyed writing: we've cleaned out the junk drawer. Config moved into vite.config.ts. The $lib alias became #lib, a real Node subpath import rather than a bespoke alias the framework had to keep in sync across Vite and TypeScript. Service workers got proper typings. Vite 8 and Rolldown are now required rather than optional.

All of that is housekeeping, and the sv migrate codemod handles most of it. The part worth your architectural attention is the feature the team called out as their vision for client-server communication — and then deliberately left behind an experimental flag.

What a remote function is

A remote function is an exported function that you import and call from a component, which always executes on the server. There is no route file, no fetch call, no request/response shape you have to define twice.

You put it in a file with a remote segment in the name — data.remote.ts, or just remote.ts:

// src/routes/blog/data.remote.ts
import * as v from 'valibot';
import { error } from '@sveltejs/kit';
import { query } from '$app/server';
import * as db from '#lib/server/database.js';

export const getPost = query(v.string(), async (slug) => {
  const [post] = await db.sql`
    SELECT * FROM post
    WHERE slug = ${slug}
  `;

  if (!post) error(404, 'Not found');
  return post;
});

And you call it:

<script>
  import { getPost } from '../data.remote';

  let { params } = $props();
  const post = $derived(await getPost(params.slug));
</script>

<h1>{post.title}</h1>

On the client, that import is compiled into a fetch wrapper that hits an HTTP endpoint SvelteKit generates for you. The database client, the environment variables, the auth helpers — none of it ships to the browser. The function is callable anywhere and runs only on the server.

There are four flavours. query reads dynamic data. form writes it through a real <form> element. command writes it from anywhere — an event handler, a keyboard shortcut. prerender runs at build time for data that changes at most once per deploy. A fifth, query.live, streams from an async generator.

Why this is more than sugar

Anyone who has built a non-trivial app knows the shape of the code this replaces. A server handler. A client function that calls it. A type for the payload and another for the response, which you either share carefully or let drift. Loading state, error state, invalidation after a mutation. Multiply by every endpoint.

Remote functions collapse that to one function and one call site, with types flowing through automatically because there is only ever one declaration. That is the tRPC pitch, except it is built into the router rather than layered on top of it — which lets the framework do things a library cannot.

Deduplication is automatic and request-scoped. Queries are keyed on their serialized arguments. getPosts() === getPosts() — call the same query in three components and you get one server round-trip, one cache entry, one instance. On the server, the same key gives you a request-scoped cache, so a query called from a layout and a page runs once.

Batching solves n+1 without a DataLoader. query.batch collects every call made within the same macrotask and hands your callback an array:

export const getWeather = query.batch(v.string(), async (cityIds) => {
  const weather = await db.sql`
    SELECT * FROM weather
    WHERE city_id = ANY(${cityIds})
  `;
  const lookup = new Map(weather.map((w) => [w.city_id, w]));

  return (cityId) => lookup.get(cityId);
});

Render fifty cities in an {#each} and you get one database call. The component code that consumes it looks exactly like the unbatched version.

Mutations refresh data in the same request. After a mutation you normally need a second round-trip to re-fetch whatever it invalidated. SvelteKit calls the alternative single-flight mutations: inside a form or command handler, calling .refresh() on a query re-runs it on the server and ships the new value back alongside the mutation result.

export const createPost = form(
  v.object({
    title: v.pipe(v.string(), v.nonEmpty()),
    content: v.pipe(v.string(), v.nonEmpty())
  }),
  async ({ title, content }) => {
    const user = await auth.getUser();
    if (!user) error(401, 'Unauthorized');

    const slug = title.toLowerCase().replace(/ /g, '-');
    await db.sql`
      INSERT INTO post (slug, title, content)
      VALUES (${slug}, ${title}, ${content})
    `;

    // Refreshed on the server, returned with this response
    void getPosts().refresh();

    redirect(303, `/blog/${slug}`);
  }
);

One request instead of two. And because form returns an object you spread onto a <form> element, complete with method and action, the non-JavaScript path still works — it submits and reloads the page like forms have always done.

The part that deserves a second read

Every remote function is a public HTTP endpoint. The docs say this plainly, and it is the single most important thing to internalise before you adopt the pattern.

When you write a route by hand, accepting untrusted input is visually obvious — you are reaching into a request object. When you write query(async (slug) => ...), it reads like a local function call and the boundary becomes easy to forget. The argument that arrives is whatever an attacker chose to POST.

This is why the schema is not optional-in-spirit. Any Standard Schema library works — Zod, Valibot — and it goes in as the first argument:

export const getPost = query(v.string(), async (slug) => { /* ... */ });

There is an escape hatch, and the docs' own comment on it tells you how to feel about it:

export const getStuff = query('unchecked', async ({ id }: { id: string }) => {
  // the shape might not actually be what TypeScript thinks
  // since bad actors might call this function with other arguments
});

Three further rules are worth pinning to the wall:

url, route and params are attacker-controlled. Inside a remote function, getRequestEvent() gives you the request context — but those three properties describe the page the call claims to have come from, not the generated endpoint. The docs are explicit: never use them for authorization. Auth checks belong on cookies and session lookups, inside the handler.

Client-requested refreshes need an explicit limit. A client can ask a mutation to refresh specific query instances. The server has to opt in via requested(getPosts, 1), and the limit argument is mandatory by design — an unbounded list of refreshes is a denial-of-service vector, since each entry makes the server validate an argument and re-fetch data.

Prefix sensitive form fields with an underscore. On a non-enhanced submission, field values are echoed back to repopulate the form after a validation failure. A leading underscore — fields._password — opts a field out of that.

What it costs you

Remote functions have been available since 2.27 and remain experimental in the 3.0 RC. You opt in explicitly:

// vite.config.js
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    sveltekit({
      experimental: { remoteFunctions: true },
      compilerOptions: { experimental: { async: true } }
    })
  ]
});

Two experimental flags, not one — the feature depends on Svelte's experimental await support for the {#each await getPosts() as post} syntax. The type for that experimental namespace carries a warning worth quoting: here be dragons. These are not subject to semantic versioning, so breaking changes or removal can happen in any release.

The sharp edges that will actually bite:

  • Queries cannot be used on a fully prerendered page (export const prerender = true, or adapter-static). Use prerender functions there instead.
  • Commands cannot be called during render, and cannot redirect.
  • Remote modules cannot live in a server-only directory, and you cannot export a schema from one — put shared schemas in a separate module.
  • Object and Map arguments get their members sorted when computing the cache key, so { limit: 10, offset: 10 } and { offset: 10, limit: 10 } hit the same entry. If order carries meaning for you, use an array.

How to think about adopting this

The honest read is that SvelteKit 3 itself is a routine upgrade and remote functions are a bet.

The RC is what it says: no further breaking changes planned before stable, and the codemod (npx sv@next migrate sveltekit-3 --tasks all --confirm) does the mechanical work. If you are on SvelteKit 2 and already on Vite 8, upgrading on a branch is a contained afternoon. The improved error handling alone is worth it — SvelteKit 3 requires Svelte 5, which means +error.svelte finally catches render errors rather than only load errors, all errors route through handleError, and stack traces get sourcemapped.

Remote functions are a different decision. Adopting them means writing application code against an API that is explicitly exempt from semver. That is fine for a greenfield internal tool and a poor trade for a codebase you will hand to a client next quarter. The middle path that actually works: use them for one bounded surface — a dashboard, an admin area — keep your existing load functions and API routes everywhere else, and see how the pattern holds under real requirements before you commit the whole app to it.

Takeaways

  • Remote functions are typed RPC built into the router. One declaration, no hand-written endpoint, types that cannot drift because there is only one source for them.
  • The framework-level wins are the real argument. Request-scoped deduplication, query.batch for n+1, and single-flight mutations that refresh queries in the same round-trip are things a userland library cannot cleanly provide.
  • Treat every remote function as a public endpoint, because it is. Validate arguments with a Standard Schema, never authorize on url/route/params, and set a real limit on client-requested refreshes.
  • Two experimental flags, no semver protection. experimental.remoteFunctions plus compilerOptions.experimental.async, both explicitly outside semantic versioning.
  • Upgrade to SvelteKit 3 on its own merits; adopt remote functions on a bounded surface. The migration is mechanical and the error handling is better. The RPC layer is a bet worth placing in one corner of an app first.

Sources

←Back to all posts
Next step

Need help shipping software?

Tell us what you're trying to build. A discovery call, a one-page summary within 48 hours, a proposal within a week.

Response · 48h·NDA on request·US contracts only