Remote functions are SvelteKit’s newer way to call the server from a component: you export a form(), query() or command() from a .remote.ts file and use it directly in markup, with the schema doing validation on both sides. For forms it replaces the actions export, superValidate and the form store with a single function and a spread. It is still experimental, which is exactly when a generator is useful: vela generate form --remote gives you a working example with the config flags already flipped.
Prerequisites
A SvelteKit project with the vela CLI:
or, for an existing project, npx vela bless. See Coming from vanilla SvelteKit. The route below lands under (app) because auth is enabled in this project.
npx sv create project with no setup. It installs Zod and flips the same config flags; the route lands at src/routes/contact, the page is plain HTML with native inputs instead of shadcn-svelte components, and success shows as a status line on the page rather than a toast. The server test is only written when the project has the test harness, which vela enable backend adds.Run the command
Same field list as the classic form generator, plus --remote:
✔ Created 4 files, modified 1 src/lib/schemas/contact.ts src/routes/(app)/contact/+page.svelte src/routes/(app)/contact/form.remote.ts src/routes/(app)/contact/server.test.ts svelte.config.js
What was generated
The config
Remote functions and the async compiler option are both behind flags. The generator adds them to svelte.config.js if they are missing:
import adapter from "@sveltejs/adapter-node";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter,
experimental: {
remoteFunctions: true,
},
},
compilerOptions: {
experimental: {
async: true,
},
},
};
export default config;
The remote function
Where the classic version has a +page.server.ts with a load and an actions export, the remote version has one function. The schema is passed as the first argument, so data arrives already validated and typed:
import { form, getRequestEvent } from "$app/server";
import { setFlash } from "sveltekit-flash-message/server";
import { contactSchema } from "$lib/schemas/contact";
export const submitContactForm = form(contactSchema, async (data) => {
const { cookies } = getRequestEvent();
setFlash({ type: "toast", message: "Form posted successfully" }, cookies);
return { success: true };
});
getRequestEvent() is how a remote function reaches cookies, locals and the rest of the request.
The page
No superForm, no store. The form is spread onto the <form> element, each input is spread from fields.<name>.as(type), and validation issues are read from fields.<name>.issues():
<script lang="ts">
import { submitContactForm } from "./form.remote";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Textarea } from "$lib/components/ui/textarea";
</script>
<section data-role="content">
<div class="flex justify-between items-center mb-4">
<h1 class="text-3xl font-bold tracking-tight">Contact</h1>
</div>
<div class="bg-card rounded-lg shadow-sm border p-4">
<form {...submitContactForm}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2 col-span-1">
<label for="name" class="text-sm font-medium">Name</label>
<Input
id="name"
{...submitContactForm.fields.name.as("text")}
required
/>
{#each submitContactForm.fields.name.issues() as issue}
<p class="text-destructive text-sm">{issue.message}</p>
{/each}
</div>
<div class="space-y-2 col-span-1">
<label for="email" class="text-sm font-medium">Email</label>
<Input
id="email"
{...submitContactForm.fields.email.as("text")}
type="email"
required
/>
{#each submitContactForm.fields.email.issues() as issue}
<p class="text-destructive text-sm">{issue.message}</p>
{/each}
</div>
<div class="space-y-2 col-span-2">
<label for="message" class="text-sm font-medium">Message</label>
<Textarea
id="message"
{...submitContactForm.fields.message.as("text")}
/>
{#each submitContactForm.fields.message.issues() as issue}
<p class="text-destructive text-sm">{issue.message}</p>
{/each}
</div>
</div>
<div class="mt-4 flex gap-2 border-t pt-4 -mx-4 px-4">
<Button type="submit">Submit</Button>
</div>
</form>
</div>
</section>
The schema and the test
The Zod schema is identical to the classic generator’s, and the server test posts to the remote function’s endpoint the same way it would post to an action.
import { z } from "zod";
export const contactSchema = z.object({
name: z.string().nonempty(),
email: z.email(),
message: z.string().optional(),
});
Try it
Open localhost:5173/contact. Submitting an invalid form renders the issues next to the fields; a valid one shows the toast.
Make it yours
Everything the form should do goes inside the remote function. Storing the message in a collection created with the resource generator is two lines:
import { form, getRequestEvent } from "$app/server";
import { setFlash } from "sveltekit-flash-message/server";
import { contactSchema } from "$lib/schemas/contact";
export const submitContactForm = form(contactSchema, async (data) => {
const { cookies, locals } = getRequestEvent();
await locals.admin.collection("messages").create(data);
setFlash({ type: "toast", message: "Thanks, we'll be in touch" }, cookies);
return { success: true };
});Tests
The generated server.test.ts covers the page and a valid submission, so a schema change that breaks the form fails in CI.
Going further
- Form actions vs remote functions compares both generators side by side
- CRUD with remote functions applies the same idea to a whole resource
- Undo with
vela destroy form contact - Pattern page: Form with remote functions · Docs


