React

SvelteKit form actions: validation and progressive enhancement

A SvelteKit form action validates on the server and returns fail() without reloading the page. Here is where the logic belongs.

Key points

  • A form action lives in +page.server.ts and only accepts POST from the same origin.
  • fail(400, data) returns the values so the form can be shown again without a reload.
  • redirect(303) after a POST stops a refresh from submitting twice.

A SvelteKit form action is a function exported from +page.server.ts: it receives the request, validates the data and returns fail() or throws redirect(). The form works without JavaScript too, because it stays a plain POST.

The smallest action

import { fail, redirect } from "@sveltejs/kit";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request }) => {
    const data = await request.formData();
    const email = String(data.get("email") ?? "").trim();
    if (!email.includes("@")) {
      return fail(400, { email, message: "Email non valida" });
    }
    await subscribe(email);
    throw redirect(303, "/grazie");
  },
} satisfies Actions;

Status 303 after a POST stops a refresh from submitting again. redirect(307) keeps the method, so a refresh becomes a second POST and the subscription arrives twice.

Showing the error without losing the data


  import { enhance } from "$app/forms";
  let { form } = $props();



  
  Iscriviti
  {#if form?.message}{form.message}{/if}

With use:enhance the POST stays a POST, but the page does not reload. Remove the directive and the form still works: that is what makes the enhancement progressive rather than a requirement.

Where validation belongs

LayerWhat it checksWhat it cannot check
Browser inputImmediate format, minimal frictionTrust: it can be bypassed
ActionDomain rules and permissionsThe state of the interface
DatabaseConstraints and uniquenessThe message to show

There is one rule: validation that protects data lives in the action. The check in the browser is there for speed, and it replaces nothing.

Common mistakes

  • fail() without the submitted values: the form is wiped and the reader types everything again.
  • redirect() called without throw: execution continues and the response is sent twice.
  • A named action and a form pointing at ?/default: the names do not match and the POST answers 404.
  • Fields whose name attribute differs from the key read in formData().

The known limitation

Form actions need a server. With adapter-static they do not exist: the form has to call an external endpoint or an API route on another origin, and you lose the server-side session.

Commands

npm create svelte@latest my-app
npx svelte-kit sync

From the catalogue: svelte-bits, flowbite-svelte and the Svelte logo block. The guides: the store that does not update and svelte-bits, measured; the comparison: Svelte against React for content sites.

Frequently asked questions

Why does the form return 405?

A form action only accepts POST from the same origin. A GET link to the page or a fetch without a POST method produces 405.

How do I read the submitted data?

With request.formData() inside the action. The result is a FormData object, so every value needs converting to a string.

What is the form field in the page for?

It holds what the action returns through fail, so you can show the values and the error message without reloading the page.

Why does redirect not work?

redirect must be thrown, or returned as a value. Call it and ignore it, and execution continues without redirecting the response.

Does it work with several forms on one page?

Yes, with named actions: each form points at a name after the slash and the action exports the key with the same name.