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

For most of SvelteKit's life a form meant a [form action](https://svelte.dev/docs/kit/form-actions): an `actions` export in `+page.server.ts`, progressive enhancement with `use:enhance`, and a library like Superforms to manage validation and state. [Remote functions](https://svelte.dev/docs/kit/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

```sh
$ vela generate form contact name:text! email:email! message:editor
```

```sh
$ 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**

```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**

```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**

```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**

```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**

```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,
    // [!code highlight:3]
    experimental: {
      remoteFunctions: true,
    },
  },
  // [!code highlight:5]
  compilerOptions: {
    experimental: {
      async: true,
    },
  },
};

export default config;
```

> **Note:**
> Auth has the same split: [`vela enable auth`](https://velastack.dev/tutorials/sveltekit-login-and-signup) generates the login, signup and settings forms on actions, and [`vela enable auth --remote`](https://velastack.dev/tutorials/sveltekit-login-remote-functions) generates them on remote functions.

## Going further

- [The form generator](https://velastack.dev/tutorials/sveltekit-form-validation-zod-superforms) and [its remote twin](https://velastack.dev/tutorials/sveltekit-remote-functions-form) in detail
- [CRUD scaffolds](https://velastack.dev/tutorials/sveltekit-crud-scaffold) and [CRUD with remote functions](https://velastack.dev/tutorials/sveltekit-crud-remote-functions)
- [Pattern pages: Form](https://velastack.dev/patterns/generate-form), [Form with remote functions](https://velastack.dev/patterns/generate-form-remote)
