Guide for SvelteKit Remote Functions. Use this skill by default for all SvelteKit projects doing type-safe client-server communication with query (data fetching), form (progressive enhancement), command (imperative actions), or data invalidation/refresh patterns.
Type-safe client-server communication for SvelteKit applications.
Use this skill by default for all SvelteKit projects. It covers:
queryformcommandRemote functions are defined in .remote.js or .remote.ts files.
// data.remote.ts
import { query } from '$app/server';
import { z } from 'zod';
export const getPosts = query(async () => {
return await db.getAllPosts();
});
export const getPost = query(z.string(), async (slug) => {
return await db.getPost(slug);
});
// Batch queries to prevent N+1 problems
export const getPostsBatch = query.batch(z.string(), async (slugs) => {
const posts = await db.getPostsBySlug(slugs);
return slugs.map((slug) => posts.find((p) => p.slug === slug));
});
<!-- +page.svelte -->
<script>
import { getPosts } from './data.remote';
</script>
{#each await getPosts() as post}
<div>{post.title}</div>
{/each}
Or using properties:
<script>
import { getPosts } from './data.remote';
const posts = getPosts();
</script>
{#if posts.loading}
Loading...
{:else if posts.error}
Error!
{:else}
{#each posts.current as post}
<div>{post.title}</div>
{/each}
{/if}
// data.remote.ts
import { form } from '$app/server';
import { redirect } from '@sveltejs/kit';
import { z } from 'zod';
export const createPost = form(async (data) => {
const title = data.get('title');
// Sensitive fields (prefixed with _) are not sent back to client
const password = data.get('_password');
await db.insert(title);
// Refresh queries that changed
getPosts().refresh();
redirect(303, '/posts');
});
// With validation schema
export const createUser = form(
z.object({
email: z.string().email(),
username: z.string().min(3),
_password: z.string().min(8) // Sensitive field
}),
async (data) => {
await db.createUser(data);
redirect(303, '/login');
}
);
<!-- +page.svelte -->
<form {...createPost}>
<input name="title" />
<input name="_password" type="password" />
<button>Create</button>
</form>
<!-- Multiple isolated form instances -->
{#each items as item}
<form {...deleteItem.for(item.id)}>
<button>Delete {item.name}</button>
</form>
{/each}
// likes.remote.ts
import { command, query } from '$app/server';
import { z } from 'zod';
export const getLikes = query(z.string(), async (id) => {
return await db.getLikes(id);
});
export const addLike = command(z.string(), async (id) => {
await db.incrementLikes(id);
getLikes(id).refresh();
});
<!-- +page.svelte -->
<script>
import { addLike, getLikes } from './likes.remote';
let { item } = $props();
const likes = getLikes(item.id);
</script>
<button onclick={() => addLike(item.id)}>
Like ({await likes})
</button>
<script>
import { getPosts } from './data.remote';
const posts = getPosts();
</script>
<button onclick={() => posts.refresh()}> Refresh </button>
Default: All queries refresh after form/command (inefficient).
Better: Specify which queries to refresh (single-flight mutation) from the client.
<form
{...createPost.enhance(async ({ submit }) => {
await submit().updates(getPosts()); // ← Specify queries
})}
></form>
await addLike(id).updates(getLikes(id));
<form
{...addTodo.enhance(async ({ data, submit }) => {
await submit().updates(getTodos().withOverride((todos) => [...todos, { text: data.get('text') }]));
})}
></form>
The override applies immediately and reverts on error.
query for fetching list and form for creationquery().refresh() before redirectquery for data and command for updatecommand().updates(query().withOverride(...))form.enhance() to customize submissionsubmit().updates() for targeted refreshFunction signatures:
query(async () => { })query(z.schema(), async (arg) => { })query(async ({}) => { }) or query(async (arg) => { }) without schemaquery(z.schema(), async (arg, event) => { }) - use getRequestEvent() insteadCalling functions:
getPosts() or getPost('slug')getPost()Request context:
// ✅ DO
import { getRequestEvent } from '$app/server';
const { cookies, locals } = getRequestEvent();
// ❌ DON'T
async (id, event) => {}; // Never use event parameter
Always validate arguments using Standard Schema (Zod recommended):
import { z } from 'zod';
// Primitives: z.string(), z.number(), z.boolean()
query(z.string(), async (id) => {});
// Objects with sensitive fields (underscore prefix not sent back)
form(
z.object({
username: z.string(),
_password: z.string().min(8)
}),
async (data) => {}
);
// Complex nested schemas
query(
z.object({
filters: z.object({
status: z.enum(['active', 'archived']),
limit: z.number().min(1).max(100)
})
}),
async (params) => {}
);
Custom error handling in hooks.server.ts:
export function handleValidationError({ event, issues }) {
return { message: 'Invalid request', code: 'VALIDATION_ERROR' };
}
getX() === getX()RequestEvent differs (no params/route.id, url.pathname is /)handleValidationError in hooks.server.ts