SvelteKit form actions give you forms that work before JavaScript loads and upgrade to in-place updates after — but the default use:enhance behaviour surprises people twice: a successful submit resets the form, and a failed one leaves focus wherever it was with no announcement, so keyboard and screen-reader users do not learn that anything went wrong.

This page builds a form action that returns field errors and submitted values with fail(), and a customised use:enhance callback that manages pending state, focus and announcements. It is the SvelteKit counterpart to the store-based patterns in Svelte store integration for forms, and follows the same principle as progressive enhancement for server-rendered forms: start from a form that posts and reloads, then intercept it.


Context and prerequisites

How the pieces fit:

  • Actions live in +page.server.ts as export const actions = { default: async ({ request }) => … }. They receive the FormData of a normal HTML POST.
  • fail(status, data) returns a non-2xx result whose data becomes the page’s form prop. Returning an object from a successful action does the same with a 2xx.
  • Without JavaScript, the browser posts, SvelteKit runs the action and renders the page with form populated. Errors display on a full reload.
  • With use:enhance, SvelteKit intercepts submit, posts with fetch, updates form and page.status, and by default resets the form on success and re-runs load functions (invalidation).

The customisation hook is the function you pass to use:enhance: it runs before submit and returns a callback that receives the result, where you decide whether to call the default update() and with which options.

One submit, with and without JavaScript Without JavaScript, the browser posts the form, the action validates and returns fail 400 with errors and values, and SvelteKit renders a full page with the form prop populated. With use:enhance, the submit is intercepted and sent with fetch; the action returns the same fail result; the enhance callback receives it, updates the form prop without a reload, keeps the typed values, moves focus to the error summary and announces the problem count. Browser SvelteKit client Action no JS: POST, full reload with form prop JS: submit intercepted by use:enhance fetch POST (FormData) fail(400, { errors, values }) update form prop; focus summary; announce

The core pattern: an action returning errors and values, and a custom enhance

// src/routes/signup/+page.server.ts
import { fail, redirect } from "@sveltejs/kit";
import { z } from "zod";
import type { Actions } from "./$types";

const Signup = z.object({
  email: z.string().trim().email("Enter an email like [email protected]."),
  name: z.string().trim().min(1, "Enter your name."),
});

export const actions: Actions = {
  default: async ({ request }) => {
    const formData = await request.formData();
    const values = { email: String(formData.get("email") ?? ""), name: String(formData.get("name") ?? "") };
    const parsed = Signup.safeParse(values);
    if (!parsed.success) {
      const errors: Record<string, string> = {};
      for (const i of parsed.error.issues) errors[String(i.path[0])] ??= i.message;
      // Never echo secrets back (passwords, card numbers): omit them from values.
      return fail(400, { errors, values });
    }
    await createAccount(parsed.data);
    redirect(303, "/welcome");
  },
};
declare function createAccount(d: z.infer<typeof Signup>): Promise<void>;
<!-- src/routes/signup/+page.svelte -->
<script lang="ts">
  import { enhance } from "$app/forms";
  import { tick } from "svelte";
  let { form } = $props();            // populated by the action result
  let pending = $state(false);
  let summary: HTMLElement | undefined = $state();

  const errors = $derived(form?.errors ?? {});
  const count = $derived(Object.keys(errors).length);

  function submitEnhance() {
    pending = true;
    return async ({ result, update }) => {
      // reset: false keeps what the user typed on success paths that stay on the page.
      await update({ reset: false });
      pending = false;
      if (result.type === "failure") {
        await tick();                 // wait for the summary to render
        summary?.focus();
      }
    };
  }
</script>

<form method="POST" use:enhance={submitEnhance} novalidate>
  {#if count}
    <div bind:this={summary} tabindex="-1" role="group" aria-labelledby="summary-title" class="error-summary">
      <h2 id="summary-title">There {count === 1 ? "is 1 problem" : `are ${count} problems`}</h2>
      <ul>{#each Object.entries(errors) as [field, msg]}<li><a href={`#${field}`}>{msg}</a></li>{/each}</ul>
    </div>
  {/if}
  <label for="name">Name</label>
  <input id="name" name="name" value={form?.values?.name ?? ""} aria-invalid={errors.name ? "true" : undefined}
    aria-describedby={errors.name ? "name-error" : undefined} autocomplete="name" />
  {#if errors.name}<p id="name-error">{errors.name}</p>{/if}
  <label for="email">Email</label>
  <input id="email" name="email" type="email" value={form?.values?.email ?? ""} aria-invalid={errors.email ? "true" : undefined}
    aria-describedby={errors.email ? "email-error" : undefined} autocomplete="email" />
  {#if errors.email}<p id="email-error">{errors.email}</p>{/if}
  <button aria-disabled={pending}>{pending ? "Creating account…" : "Create account"}</button>
</form>

Step-by-step walkthrough

  1. Validate in the action with a schema. The action is the authority; it runs whether or not JavaScript loaded.
  2. Return fail(400, { errors, values }). Errors keyed by field name, and the submitted values (minus secrets) so the page can re-populate inputs on both the no-JS and enhanced paths.
  3. Render inputs from form?.values. Using value={form?.values?.email ?? ""} makes the no-JS reload show what the user typed.
  4. Customise use:enhance. Set pending before submit, call update({ reset: false }) in the callback, and clear pending after.
  5. Move focus after a failure. Wait for the DOM with tick(), then focus the error summary, whose links jump to each field — the pattern in building an accessible error summary.
  6. Redirect on success with 303. A redirect after POST prevents resubmission on reload; with use:enhance, SvelteKit follows it client-side.

Why the no-JavaScript path still matters

It is tempting to treat the non-enhanced path as a legacy fallback. In practice it runs more often than expected: while the page is still hydrating on a slow phone, when a script fails to load behind a corporate proxy, when a browser extension throws during startup, and for every user who submits within the first second of a server-rendered page appearing. Designing the action so that the full-reload response is complete — errors, values, summary — means those users get a working form instead of a form that silently does nothing. It also makes the action easy to test with a plain HTTP request, independent of any client code.

use:enhance defaults and when to override them By default use:enhance resets the form after a successful result; override with reset false when the user stays on the page to keep editing. It invalidates and reruns load functions after success; keep it when the page shows data the action changed, and skip it when nothing else depends on the action. It applies the result to the form prop; keep it. It does not move focus after a failure; add focus management yourself. Default behaviour Keep when Override when reset form on success the form is for creating new items editing continues on the page invalidate load data the page shows changed data nothing else depends on it apply result to form prop always focus after failure not handled: add it yourself

Failure modes and edge cases

1. Inputs cleared after a successful save on an edit page

On success, update() resets the form, which restores each input’s defaultValue — the value it had at server render, not the saved one. Use update({ reset: false }) on edit forms, or re-render with the saved values returned from the action.

2. Echoing passwords in values

Returning the whole FormData as values puts passwords and card numbers into the rendered HTML on the no-JS path and into client state. Build values from an allow-list of safe fields.

3. Double submission

Buttons remain clickable during the request. Guard in the enhance function (if (pending) return cancel() using the cancel argument it receives) and make the action idempotent, per handling double submit and idempotency.

4. Client-side validation on top

For instant feedback, add a client schema check on blur; keep the action as the authority. Libraries such as Superforms automate the sharing, covered in validating SvelteKit forms with Superforms and Zod.

5. Files and large bodies

File inputs require enctype="multipart/form-data"; SvelteKit parses them into File objects in request.formData(). Server body limits (the adapter’s BODY_SIZE_LIMIT) apply, and exceeding them fails before your action runs — validate size on the client too.

What the user experiences on each path Before hydration the form posts and reloads, and the page shows the summary and field errors with values preserved, with focus at the top of the page. After hydration with default enhance the page updates in place without moving focus, so keyboard and screen-reader users may not notice the errors. After hydration with the customised enhance the page updates in place, keeps values, focuses the error summary and announces the problem count. Before hydration POST + reload. Summary and values shown. Focus at page top. Default use:enhance In-place update. Focus unchanged; errors easy to miss. Custom enhance In-place update. Values kept; summary focused. Problem count announced.

Verification checklist


Frequently Asked Questions

Why 303 for the redirect after a successful action?

303 See Other tells the browser to follow with a GET, so reloading the destination page does not re-POST the form. SvelteKit’s redirect defaults are designed around this; use 303 for post-action redirects.

Can I use named actions for multiple forms on one page?

Yes. Export actions = { create, delete } and point each form at ?/create or ?/delete. The returned form prop is shared, so include which action produced the result (for example an action: "create" field) so each form renders only its own errors.

Does use:enhance work with Svelte 4 syntax?

Yes; the action API is the same. The example uses Svelte 5 runes ($props, $state, $derived) for component state, but export let form and let pending = false work identically in Svelte 4.


Related

← Svelte Store Integration for Forms