SvelteKit Remote Functions: Type-Safe Server Calls Without the Boilerplate

If you have been building SvelteKit apps, you know the drill: create a +server.ts for your API, define types, write fetch calls, and manually wire everything together. SvelteKit Remote Functions change all of that. Introduced experimentally in SvelteKit 2.27 and now a headline feature of the SvelteKit 3 Release Candidate, remote functions let you call server code directly from your components with full end-to-end type safety.

In this guide, you will learn how to use all four function types — query, command, form, and prerender — with practical examples that you can apply to your own projects.

What Are Remote Functions?

Remote functions are server-side functions defined in .remote.ts files that the SvelteKit compiler transforms into HTTP endpoints with auto-generated typed client wrappers. You write server code, import it in a component, and call it as if it were a local function. The compiler handles serialization, validation, caching, and type inference.

Here is what this looks like in practice:

// src/lib/posts.remote.ts
import * as v from "valibot";

import { query } from "$app/server";

import * as db from "$lib/server/db";

export const getPost = query(v.string(), async (slug) => {
  const post = await db.findPost(slug);
  if (!post) error(404, "Not found");
  return post;
});
<!-- src/routes/posts/[slug]/+page.svelte -->
<script>
import { getPost } from "$lib/posts.remote.js";

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

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

No +server.ts, no manual fetch, no type definitions. The compiler infers everything from the Valibot schema.

Setting Up Remote Functions

To enable remote functions, you need to configure two experimental flags:

// svelte.config.js
export default {
  kit: {
    experimental: {
      remoteFunctions: true,
    },
  },
  compilerOptions: {
    experimental: {
      async: true, // enables await in component markup
    },
  },
};

The async compiler option is a separate Svelte 5 feature that allows await expressions directly in your templates. While not strictly required for remote functions, they are designed to work together.

The Four Function Types

Remote functions are imported from $app/server and come in four flavors, each designed for a specific use case.

1. query() — Read Server Data

query() is the most common function type. It fetches data from the server with built-in caching and request deduplication.

// src/lib/products.remote.ts
import * as v from "valibot";

import { query } from "$app/server";

import * as db from "$lib/server/db";

export const getProducts = query(
  v.object({
    category: v.string(),
    limit: v.number(),
  }),
  async ({ category, limit }) => {
    return await db.sql`
      SELECT * FROM products
      WHERE category = ${category}
      ORDER BY created_at DESC
      LIMIT ${limit}
    `;
  },
);
<script>
import { getProducts } from "$lib/products.remote.js";

const products = await getProducts({ category: "electronics", limit: 10 });
</script>

{#each products as product}
  <div>{product.name} — ${product.price}</div>
{/each}

Key behaviors of query:

  • Automatic caching: Arguments are serialized as cache keys. Object key order does not matter — { limit: 10, offset: 0 } and { offset: 0, limit: 10 } produce the same cache key.
  • Request deduplication: Multiple components calling the same query with the same arguments in a single render cycle result in one server call.
  • SSR embedding: Data fetched during SSR is embedded in the page payload, so the client does not make a second request on hydration.
  • Manual refresh: Call .refresh() to re-fetch the latest value from the server.

2. query.batch() — Solve the N+1 Problem

When multiple components independently query related data, query.batch() groups concurrent calls into a single server request:

// src/lib/users.remote.ts
import * as v from "valibot";

import { query } from "$app/server";

export const getUser = query.batch(v.string(), async (userIds) => {
  // userIds is an array of all requested IDs
  const users = await db.sql`
      SELECT * FROM users WHERE id = ANY(${userIds})
    `;
  // Return a Map from input to result
  return new Map(users.map((u) => [u.id, u]));
});

If ten components each call getUser(id) in the same macrotask, only one database query runs with all ten IDs.

3. query.live() — Real-Time Streaming

For data that changes over time, query.live() streams updates using an async generator:

// src/lib/notifications.remote.ts
import * as v from "valibot";

import { query } from "$app/server";

export const getNotifications = query.live(v.string(), async function* (userId) {
  while (true) {
    const notifications = await db.getUnread(userId);
    yield notifications;
    await new Promise((r) => setTimeout(r, 5000));
  }
});
<script>
import { getNotifications } from "$lib/notifications.remote.js";

const notifications = await getNotifications(userId);
</script>

<span>You have {notifications.length} unread notifications</span>

The connection stays open while the component is mounted. Multiple instances share one connection, and SvelteKit handles automatic reconnection with exponential backoff.

4. command() — Imperative Mutations

Use command() for mutations triggered by user interactions outside of forms — button clicks, drag-and-drop, keyboard shortcuts:

// src/lib/likes.remote.ts
import * as v from "valibot";

import { command, query } from "$app/server";

export const getLikes = query(v.string(), async (postId) => {
  const result = await db.sql`SELECT likes FROM posts WHERE id = ${postId}`;
  return result[0].likes;
});

export const addLike = command(v.string(), async (postId) => {
  await db.sql`UPDATE posts SET likes = likes + 1 WHERE id = ${postId}`;
  return getLikes(postId).refresh();
});
<script>
import { getLikes, addLike } from "$lib/likes.remote.js";

let { postId } = $props();
const likes = await getLikes(postId);
</script>

<button onclick={() => addLike(postId)}>
  {likes} Likes
</button>

Important: Unlike form(), a successful command() does not auto-invalidate queries. You must explicitly call .refresh() on related queries or use .updates() for optimistic updates.

5. form() — Progressive Enhancement Forms

form() is designed for HTML form submissions with built-in progressive enhancement — it works even before JavaScript hydrates:

// src/routes/contact/contact.remote.ts
import * as v from "valibot";

import { form } from "$app/server";

import * as db from "$lib/server/db";

export const submitContact = form(
  v.object({
    name: v.pipe(v.string(), v.minLength(2)),
    email: v.pipe(v.string(), v.email()),
    message: v.pipe(v.string(), v.minLength(10)),
  }),
  async (data) => {
    await db.insertContactMessage(data);
    return { success: true };
  },
);
<script>
import { submitContact } from "./contact.remote.js";
</script>

<form {...submitContact.enhance()}>
  <input name="name" required />
  <input name="email" type="email" required />
  <textarea name="message" required></textarea>
  <button>Send</button>
</form>

Key behaviors of form:

  • Progressive enhancement: Works without JavaScript using standard HTML form submission.
  • Auto-invalidation: On success, all queries and load functions are automatically refreshed.
  • Rich type support: Uses a custom binary format (application/x-sveltekit-formdata) to serialize Date, Map, Set, and File objects.
  • Field-level validation: Validation errors are returned per-field for granular error display.

6. prerender() — Build-Time Data

For content that rarely changes, prerender() fetches data at build time and serves it as static assets:

// src/lib/posts.remote.ts
import * as v from "valibot";

import { prerender } from "$app/server";

export const getPost = prerender(
  v.string(),
  async (slug) => {
    const post = await db.findPost(slug);
    if (!post) error(404, "Not found");
    return post;
  },
  {
    inputs: () => ["welcome", "about", "remote-functions"],
    dynamic: true, // allow runtime fallback for unknown slugs
  },
);

The inputs callback tells SvelteKit which argument values to prerender at build time. Setting dynamic: true allows runtime fetches for slugs not covered during the build.

End-to-End Type Safety

The secret sauce of remote functions is how type safety works. You define a validation schema as the first argument, and the compiler propagates these types from server to client automatically:

// Schema defines the contract
const schema = v.object({
  page: v.number(),
  search: v.optional(v.string()),
});

// Handler receives validated, typed input
export const searchProducts = query(schema, async (input) => {
  // input is typed as { page: number; search?: string }
  return await db.search(input);
  // Return type is inferred and available on the client
});

On the client side, your IDE provides full autocomplete for both arguments and return values. No shared type files, no satisfies, no codegen step.

This validation is not just for developer experience — it is also a security requirement. Every remote function compiles to a publicly accessible HTTP endpoint, so input validation prevents malformed or malicious data from reaching your server logic.

Optimistic Updates

Both command() and form() support optimistic updates using .updates() combined with .withOverride():

<script>
import { addLike, getLikes } from "$lib/likes.remote.js";

let { postId } = $props();
const likes = await getLikes(postId);
</script>

<button onclick={() => addLike(postId).updates(getLikes(postId).withOverride((current) => current + 1))}>
  {likes} Likes
</button>

The override is applied immediately to the UI. If the server mutation fails, the value automatically reverts. If it succeeds, the server’s actual response replaces the override.

How It Works Under the Hood

Understanding the compilation process helps debug and optimize your remote functions:

  1. Build time: The Vite plugin detects .remote.ts files and generates an HTTP endpoint for each exported function.
  2. Server bundle: The original function stays as-is.
  3. Client bundle: The export is replaced with a typed fetch wrapper that calls the generated endpoint.
  4. Serialization: Arguments and return values are serialized using devalue, which supports Date, Map, Set, BigInt, and circular references.
  5. SSR: During server-side rendering, query results are embedded in a query_responses map in the page payload — no client re-fetch on hydration.

Remote Functions vs. Alternatives

vs. SvelteKit 2 load + actions

Aspectload + actionsRemote Functions
Data co-locationPage-level onlyCall from any component
Type safetyManual or inferredSchema-driven, automatic
Boilerplate+page.server.ts + +server.tsSingle .remote.ts file
Progressive enhancementManual use:enhanceBuilt-in via form()
Cache invalidationinvalidateAll()Automatic (form) or explicit (command)
Real-time dataNot built-inquery.live()

vs. tRPC

AspecttRPCRemote Functions
SetupRouter + client + adapterOne config flag
Framework couplingFramework-agnosticSvelteKit-native
BatchingBuilt-inquery.batch()
StreamingSubscriptionsquery.live()
Optimistic updatesManualBuilt-in .withOverride()
Progressive enhancementNot supportedBuilt-in via form()

Performance Considerations

Remote functions offer several performance advantages over traditional API patterns:

  1. Zero client re-fetch on hydration: SSR query results are serialized into the page payload. The client reuses them without making a second HTTP request, reducing Time to Interactive.

  2. Request deduplication: If multiple components render the same query, SvelteKit sends only one request. This prevents the waterfall problem common in component-level data fetching.

  3. Single-flight mutations: When a form() or command() handler calls .refresh() on a query, the refreshed data rides back in the same HTTP response. No additional round-trip needed.

  4. Bundle size: No API client library required. The generated fetch wrappers are minimal compared to tRPC’s client bundle.

  5. Prerender for static content: prerender() eliminates runtime data-fetch latency entirely for content that does not change between deployments.

Limitations to Know

Before adopting remote functions, be aware of these constraints:

  • Experimental: The API is behind a flag and may change. Production use requires accepting this risk.
  • Requires a server: query, command, and form do not work with adapter-static. You need a server-backed adapter (Node.js, Vercel, Cloudflare Workers, etc.).
  • No cross-origin support: Remote function endpoints are same-origin only. Cross-site requests are blocked by default.
  • Prerendered pages cannot use query: Pages with export const prerender = true cannot call dynamic queries. Use prerender() instead.
  • No official testing story: Unit testing remote functions is an open issue. Integration testing with the full SvelteKit server is the current recommended approach.

Migration from SvelteKit 2

If you are upgrading from SvelteKit 2, an automated migration tool is available:

npx sv@next migrate sveltekit-3 --tasks all --confirm

Remote functions are additive — they do not replace load functions or form actions. You can adopt them incrementally. A practical migration strategy:

  1. Start with read-heavy pages: replace +page.server.ts load functions with query().
  2. Move form actions to form() for better type safety and co-location.
  3. Replace +server.ts API routes with command() for non-form mutations.
  4. Use prerender() for content pages that do not need runtime data.

Conclusion

SvelteKit Remote Functions represent a significant shift in how we build full-stack web applications. By moving the server-client boundary into the compiler, they eliminate an entire category of boilerplate while improving type safety, performance, and developer experience.

The combination of query() for reads, command() and form() for mutations, and prerender() for static data gives you a complete toolkit for every data-fetching pattern — all with end-to-end type safety and zero manual wiring.

While still experimental, remote functions are the future direction of SvelteKit. Starting to learn and experiment with them now will prepare you for the stable release and give you a head start in building faster, safer web applications.

Frequently Asked Questions

What are SvelteKit Remote Functions?

Remote functions are a SvelteKit feature that lets you write server-side code in .remote.ts files and call them directly from components with full type safety. The compiler automatically generates HTTP endpoints and typed client wrappers, eliminating the need for manual API routes.

Are SvelteKit Remote Functions stable?

Remote functions were introduced as experimental in SvelteKit 2.27 and remain behind an experimental flag as of SvelteKit 3 RC (August 2026). The API may change, but the core concepts are solid and actively developed.

What is the difference between query and command in SvelteKit?

query() is for reading data from the server with automatic caching and deduplication. command() is for mutations triggered by user actions like button clicks. The key difference is that command does not auto-invalidate queries, while form() does.

Can I use Remote Functions with adapter-static?

No. query, form, and command require a server-backed adapter like adapter-node, adapter-vercel, or adapter-cloudflare. Only prerender() works without a runtime server since it fetches data at build time.

How do Remote Functions compare to tRPC?

Remote functions are SvelteKit-native, requiring zero configuration beyond the experimental flag. They offer equivalent type safety, built-in optimistic updates, progressive enhancement via form(), and streaming via query.live(). tRPC requires router setup and adapter configuration.

Do Remote Functions support real-time data?

Yes. query.live() accepts an async generator and streams values to the client. It automatically manages connections, reconnects with exponential backoff, and shares connections across multiple component instances.

How does type safety work in Remote Functions?

You define a validation schema (using Zod, Valibot, or any Standard Schema library) as the first argument. The compiler infers argument and return types from this schema and propagates them to the client-side wrapper, giving you end-to-end type safety with zero manual type definitions.

entr