Form actions vs remote functions in SvelteKit, and how to generate either

SvelteKit now has two ways to handle a form on the server. Here is the same contact form built both ways, file by file, with the trade-offs, and the one flag that switches the vela generators between them.

2 min read formsremote-functionssuperformssveltekit

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

$ vela generate form contact name:text! email:email! message:editor
$ vela generate form --remote contact name:text! email:email! message:editor

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).

src/routes/(app)/contact/+page.server.ts
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.

src/routes/(app)/contact/form.remote.ts
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:

src/routes/(app)/contact/+page.svelte
<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():

src/routes/(app)/contact/+page.svelte
<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.

svelte.config.js
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;
Note
Auth has the same split: vela enable auth generates the login, signup and settings forms on actions, and vela enable auth --remote generates them on remote functions.

Going further

Related tutorials

Build a validated form in SvelteKit with Zod and Superforms in one command

A SvelteKit form with server-side validation means a schema, a form action, a page wired to Superforms, and a test. vela generate form writes all four from a single field list, so you start from working code instead of boilerplate.

4 min read

SvelteKit remote functions - a validated form without form actions

Remote functions let a SvelteKit form post to a typed server function instead of a form action. vela generate form --remote scaffolds the form, the remote function, the Zod schema and the config flags so you can try the new model in a minute.

3 min read

CRUD with SvelteKit remote functions - a scaffold without form actions

The same list, create, view and edit pages as the classic scaffold, but the forms post to typed remote functions instead of form actions. vela generate scaffold --remote writes the routes, the .remote.ts handlers, the schema, the collection and the tests.

3 min read