Skip to main content
Technology & EngineeringSvelte260 lines

Form Actions

SvelteKit form actions for progressive enhancement with server-side form handling

Quick Summary33 lines
You are an expert in SvelteKit form actions, helping developers build forms that work without JavaScript and progressively enhance with client-side interactivity.

## Key Points

- **Forgetting `method="POST"` on Forms** — leaving the default `method="GET"`, which sends form data as URL query parameters instead of triggering the action handler, causing silent failures.
- **Always use `fail()` for validation errors** rather than throwing or returning plain objects. `fail` sets the correct HTTP status and makes the data available through `form`.
- **Redirect after mutation** with `redirect(303, '/path')` to prevent form resubmission on back/refresh (Post-Redirect-Get pattern).
- **Preserve user input on error** by returning submitted values (like `email`) from `fail()` and repopulating form fields.
- **Use `use:enhance`** on all forms for a smoother experience. It gracefully degrades — the form still works without JS.
- **Keep actions in `+page.server.js`** (not `+server.js`). Actions are tightly coupled to pages and benefit from automatic `form` prop binding.
- **Forgetting `method="POST"`.** Forms default to GET, which does not trigger actions.
- **Returning non-serializable data from actions.** Action return values must be serializable (no functions, classes, or circular references).
- **Not awaiting `update()` in custom enhance.** Skipping `await update()` means load functions are not re-run and the page does not reflect the change.
- **Using `action="?/name"` with `default`.** A page with only a `default` action should use `action=""` or omit the attribute, not `action="?/default"`.
- **CSRF token confusion.** SvelteKit handles CSRF protection automatically by checking the `Origin` header. Do not implement a custom CSRF solution unless you have disabled the built-in one.

## Quick Example

```svelte
<form method="POST" action="?/upload" enctype="multipart/form-data" use:enhance>
  <input type="file" name="avatar" accept="image/*" />
  <button>Upload</button>
</form>
```

```svelte
<form method="POST" action="/todos?/create" use:enhance>
  <input name="text" />
  <button>Quick Add Todo</button>
</form>
```
skilldb get svelte-skills/Form ActionsFull skill: 260 lines
Paste into your CLAUDE.md or agent config

Form Actions — SvelteKit

You are an expert in SvelteKit form actions, helping developers build forms that work without JavaScript and progressively enhance with client-side interactivity.

Core Philosophy

SvelteKit form actions are built on a simple but powerful idea: forms should work without JavaScript. The <form method="POST"> element is the web platform's native mechanism for submitting data to a server, and SvelteKit embraces this by making server-side form handling the default behavior. Progressive enhancement via use:enhance layers on a better experience for JavaScript-enabled clients, but the baseline functionality never depends on client-side code. This is not just about accessibility — it makes forms resilient, testable, and debuggable.

The Post-Redirect-Get pattern is central to how actions work. After a successful mutation, you redirect with redirect(303, '/path') so the browser makes a GET request to the target. This prevents form resubmission on back/refresh and keeps browser history clean. When validation fails, fail() returns data without redirecting, preserving the user's input and showing error messages. This two-path flow — redirect on success, return errors on failure — is the pattern every form action should follow.

Actions are deliberately co-located with their pages in +page.server.js. This tight coupling is a feature: the form's markup, its validation logic, and its server handler all live together, making it easy to understand the full lifecycle of a form submission. Moving action logic into separate API endpoints (+server.js) breaks this co-location and loses the automatic form prop binding that makes error display trivial.

Anti-Patterns

  • Forgetting method="POST" on Forms — leaving the default method="GET", which sends form data as URL query parameters instead of triggering the action handler, causing silent failures.

  • Returning Non-Serializable Data from Actions — returning class instances, functions, or objects with circular references from an action. Action return values must be serializable since they are transferred between server and client.

  • Skipping await update() in Custom Enhance — providing a custom callback to use:enhance but not calling await update(), which means load functions are not re-run and the page does not reflect the mutation.

  • Implementing Custom CSRF Protection — adding manual CSRF tokens when SvelteKit already handles CSRF protection automatically by verifying the Origin header. Custom solutions conflict with the built-in mechanism.

  • Not Preserving User Input on Validation Failure — returning fail(400, { missing: true }) without including the submitted values (like email), forcing users to re-enter everything after a validation error.

Overview

SvelteKit form actions allow you to define server-side handlers for <form> submissions directly alongside your pages. Forms work natively with standard POST requests — no JavaScript required — and SvelteKit's use:enhance directive layering on client-side enhancement for a seamless user experience.

Core Concepts

Defining Actions

Actions are exported from +page.server.js and handle POST requests.

// src/routes/login/+page.server.js
import { fail, redirect } from '@sveltejs/kit';

export const actions = {
  default: async ({ request, cookies }) => {
    const data = await request.formData();
    const email = data.get('email');
    const password = data.get('password');

    if (!email || !password) {
      return fail(400, { email, missing: true });
    }

    const user = await authenticate(email, password);
    if (!user) {
      return fail(401, { email, incorrect: true });
    }

    cookies.set('session', user.sessionId, { path: '/' });
    redirect(303, '/dashboard');
  }
};

Named Actions

Multiple actions per page use named exports:

// src/routes/todos/+page.server.js
export const actions = {
  create: async ({ request }) => {
    const data = await request.formData();
    await db.createTodo(data.get('text'));
  },
  delete: async ({ request }) => {
    const data = await request.formData();
    await db.deleteTodo(data.get('id'));
  }
};

Invoke named actions via the action attribute with a query parameter:

<form method="POST" action="?/create">
  <input name="text" />
  <button>Add</button>
</form>

<form method="POST" action="?/delete">
  <input type="hidden" name="id" value={todo.id} />
  <button>Delete</button>
</form>

The fail Helper

Return validation errors without redirecting. Data returned by fail is available in the page via $page.form or the form prop.

import { fail } from '@sveltejs/kit';

export const actions = {
  register: async ({ request }) => {
    const data = await request.formData();
    const email = data.get('email');
    const password = data.get('password');

    const errors = {};
    if (!email?.includes('@')) errors.email = 'Invalid email';
    if (password?.length < 8) errors.password = 'Must be 8+ chars';

    if (Object.keys(errors).length) {
      return fail(422, { errors, email });
    }

    await createUser(email, password);
    return { success: true };
  }
};

Displaying Action Results

<!-- src/routes/register/+page.svelte -->
<script>
  let { form } = $props();
</script>

{#if form?.success}
  <p class="success">Account created!</p>
{/if}

<form method="POST" action="?/register">
  <label>
    Email
    <input name="email" value={form?.email ?? ''} />
    {#if form?.errors?.email}
      <span class="error">{form.errors.email}</span>
    {/if}
  </label>

  <label>
    Password
    <input name="password" type="password" />
    {#if form?.errors?.password}
      <span class="error">{form.errors.password}</span>
    {/if}
  </label>

  <button>Register</button>
</form>

Implementation Patterns

Progressive Enhancement with use:enhance

The enhance action intercepts form submission, sends it via fetch, and updates the page without a full reload.

<script>
  import { enhance } from '$app/forms';
</script>

<form method="POST" action="?/create" use:enhance>
  <input name="text" />
  <button>Add Todo</button>
</form>

Custom Enhance Behavior

Pass a callback to use:enhance for custom handling:

<script>
  import { enhance } from '$app/forms';
  let submitting = $state(false);
</script>

<form
  method="POST"
  action="?/save"
  use:enhance={() => {
    submitting = true;
    return async ({ update, result }) => {
      submitting = false;
      if (result.type === 'success') {
        // Custom success handling
        showToast('Saved!');
      }
      await update(); // apply default behavior (rerun load, etc.)
    };
  }}
>
  <button disabled={submitting}>
    {submitting ? 'Saving...' : 'Save'}
  </button>
</form>

File Uploads

Forms handle file uploads natively with enctype="multipart/form-data":

<form method="POST" action="?/upload" enctype="multipart/form-data" use:enhance>
  <input type="file" name="avatar" accept="image/*" />
  <button>Upload</button>
</form>
// +page.server.js
export const actions = {
  upload: async ({ request }) => {
    const data = await request.formData();
    const file = data.get('avatar');

    if (file.size > 5_000_000) {
      return fail(413, { error: 'File too large (max 5MB)' });
    }

    const buffer = Buffer.from(await file.arrayBuffer());
    await saveFile(buffer, file.name);
    return { success: true };
  }
};

Cross-Page Action Calls

Invoke actions on other pages by specifying the full path:

<form method="POST" action="/todos?/create" use:enhance>
  <input name="text" />
  <button>Quick Add Todo</button>
</form>

Best Practices

  • Always use fail() for validation errors rather than throwing or returning plain objects. fail sets the correct HTTP status and makes the data available through form.
  • Redirect after mutation with redirect(303, '/path') to prevent form resubmission on back/refresh (Post-Redirect-Get pattern).
  • Preserve user input on error by returning submitted values (like email) from fail() and repopulating form fields.
  • Use use:enhance on all forms for a smoother experience. It gracefully degrades — the form still works without JS.
  • Keep actions in +page.server.js (not +server.js). Actions are tightly coupled to pages and benefit from automatic form prop binding.

Common Pitfalls

  • Forgetting method="POST". Forms default to GET, which does not trigger actions.
  • Returning non-serializable data from actions. Action return values must be serializable (no functions, classes, or circular references).
  • Not awaiting update() in custom enhance. Skipping await update() means load functions are not re-run and the page does not reflect the change.
  • Using action="?/name" with default. A page with only a default action should use action="" or omit the attribute, not action="?/default".
  • CSRF token confusion. SvelteKit handles CSRF protection automatically by checking the Origin header. Do not implement a custom CSRF solution unless you have disabled the built-in one.

Install this skill directly: skilldb add svelte-skills

Get CLI access →