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 formsremote-functionszodvalidationgeneratorssveltekit

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:

$ npx vela create my-app

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.

Note
The command also runs in a plain 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:

$ vela generate form --remote contact name:text! email:email! message:editor
✔ 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:

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;

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:

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 };
});

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():

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>

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.

src/lib/schemas/contact.ts
import { z } from "zod";

export const contactSchema = z.object({
  name: z.string().nonempty(),
  email: z.email(),
  message: z.string().optional(),
});

Try it

$ vela dev

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:

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, locals } = getRequestEvent();
  await locals.admin.collection("messages").create(data);
  setFlash({ type: "toast", message: "Thanks, we'll be in touch" }, cookies);
  return { success: true };
});
Note
Remote functions are experimental in SvelteKit and their API can still change between minor versions. The generated code targets the version of SvelteKit in your project; if you upgrade and something breaks, regenerate the form or compare against the pattern page, which always shows the current output.

Tests

$ npm run test:server

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

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

Generate Zod schemas for your SvelteKit models from a field list

Stop hand-writing a Zod schema for every form and endpoint. One vela command turns a field list into a typed schema that drops straight into Superforms, remote functions and server code.

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