For most of SvelteKit’s life a form meant a form action: an actions export in +page.server.ts, progressive enhancement with use:enhance, and a library like Superforms to manage validation and state. Remote functions are the newer alternative: a typed form() exported from a .remote.ts file, used directly in markup. Both are supported, both are generated by vela, and the choice is mostly about how much of your app is already on one side.
The same form, twice
Both commands produce the same schema and the same test. The difference is in two files.
Server side
The form action lives in +page.server.ts, next to a load that initialises the form. Validation is explicit: call superValidate, check form.valid, return fail(400).
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { contactSchema } from "$lib/schemas/contact";
import { setFlash } from "sveltekit-flash-message/server";
export const load = async () => {
const form = await superValidate(zod4(contactSchema));
return { form };
};
export const actions = {
default: async ({ request, cookies }) => {
const form = await superValidate(request, zod4(contactSchema));
if (!form.valid) {
return fail(400, { form });
}
setFlash({ type: "toast", message: "Form posted successfully" }, cookies);
return { form };
},
};
The remote function is one call. The schema is an argument, so the handler only runs with valid, typed data, and there is no load because the form has no server-provided initial state.
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 };
});
Client side
With actions, the page builds a superForm from data.form, opts into client validation with the Zod adapter, and binds every input to a store. Fields are Form.Field components from shadcn-svelte’s form package:
<script lang="ts">
import { untrack } from "svelte";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { contactSchema } from "$lib/schemas/contact";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { Textarea } from "$lib/components/ui/textarea";
let { data } = $props();
const form = superForm(
untrack(() => data.form),
{
validators: zod4Client(contactSchema),
},
);
const { form: formData } = form;
</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 method="POST">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<Form.Field {form} name="name" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Input
{...props}
type="text"
bind:value={$formData.name}
required
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
<Form.Field {form} name="email" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Input
{...props}
type="email"
bind:value={$formData.email}
required
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
<Form.Field {form} name="message" class="col-span-2">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Message</Form.Label>
<Textarea {...props} bind:value={$formData.message} />
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
</div>
<div class="mt-4 flex gap-2 border-t pt-4 -mx-4 px-4">
<Form.Button>Submit</Form.Button>
</div>
</form>
</div>
</section>
With remote functions, the page imports the function and spreads it. Inputs get their attributes from fields.<name>.as(type) and errors 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>
How to choose
Pick form actions if your app already uses them, you rely on Superforms features such as nested data, multi-step forms or the tainted-form guard, or you want to stay on stable APIs. This is the default for every vela generator.
Pick remote functions if you are starting fresh and comfortable with an experimental API, you want less ceremony per form, or you also want query() and command() for non-form server calls in the same style. The --remote flag makes them the default for that generator, and the config flags are added for you.
You can mix. A remote-function form next to an action-based one in the same app is fine; they share the schema directory and the components. The only project-wide switch is the two experimental flags in svelte.config.js, which the --remote generators set the first time.
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;
vela enable auth generates the login, signup and settings forms on actions, and vela enable auth --remote generates them on remote functions.

