4 min read
On this page

Form Actions

Overview

SvelteKit form actions let you handle form submissions on the server using the same file that loads data for the page. You export an actions object from +page.server.ts, and each action receives the form data, validates it, and either returns errors or redirects the user. The form works without JavaScript. This is a fundamental shift from the SPA pattern of intercepting form submissions in client-side code.

The actions Export

Form actions are defined in +page.server.ts alongside (or instead of) the load function.

// src/routes/login/+page.server.ts
import type { Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/database';
import { verifyPassword } from '$lib/server/auth';

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

    if (!email || !password) {
      return fail(400, { email, message: 'Email and password are required' });
    }

    const user = await db.user.findUnique({ where: { email } });

    if (!user || !await verifyPassword(password, user.passwordHash)) {
      return fail(401, { email, message: 'Invalid email or password' });
    }

    const session = await createSession(user.id);
    cookies.set('session', session.id, {
      path: '/',
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
      maxAge: 60 * 60 * 24 * 7
    });

    redirect(303, '/dashboard');
  }
};
<!-- src/routes/login/+page.svelte -->
<script lang="ts">
  let { form } = $props();
</script>

<h1>Log In</h1>

{#if form?.message}
  <p class="error">{form.message}</p>
{/if}

<form method="POST">
  <label>
    Email
    <input name="email" type="email" value={form?.email ?? ''} />
  </label>

  <label>
    Password
    <input name="password" type="password" />
  </label>

  <button type="submit">Log In</button>
</form>

When the user submits the form, the browser sends a POST request to the current page. SvelteKit runs the default action. If the action returns fail(), the page re-renders with the form data available through the form prop. If the action calls redirect(), the user is sent to the new page.

Default & Named Actions

A page can have one default action and any number of named actions.

// src/routes/todos/+page.server.ts
import type { Actions, PageServerLoad } from './$types';
import { fail } from '@sveltejs/kit';
import { db } from '$lib/server/database';

export const load: PageServerLoad = async ({ locals }) => {
  const todos = await db.todo.findMany({
    where: { userId: locals.user.id },
    orderBy: { createdAt: 'desc' }
  });
  return { todos };
};

export const actions: Actions = {
  create: async ({ request, locals }) => {
    const formData = await request.formData();
    const text = formData.get('text') as string;

    if (!text?.trim()) {
      return fail(400, { text, error: 'Todo text is required' });
    }

    await db.todo.create({
      data: { text: text.trim(), userId: locals.user.id }
    });
  },

  toggle: async ({ request, locals }) => {
    const formData = await request.formData();
    const id = formData.get('id') as string;

    const todo = await db.todo.findUnique({ where: { id } });
    if (!todo || todo.userId !== locals.user.id) {
      return fail(404, { error: 'Todo not found' });
    }

    await db.todo.update({
      where: { id },
      data: { completed: !todo.completed }
    });
  },

  delete: async ({ request, locals }) => {
    const formData = await request.formData();
    const id = formData.get('id') as string;

    await db.todo.delete({
      where: { id, userId: locals.user.id }
    });
  }
};

Named actions are targeted using the action attribute with a query parameter.

<!-- src/routes/todos/+page.svelte -->
<script lang="ts">
  let { data, form } = $props();
</script>

<h1>Todos</h1>

<form method="POST" action="?/create">
  <input name="text" value={form?.text ?? ''} placeholder="What needs doing?" />
  <button type="submit">Add</button>
  {#if form?.error}
    <p class="error">{form.error}</p>
  {/if}
</form>

<ul>
  {#each data.todos as todo}
    <li>
      <form method="POST" action="?/toggle" style="display: inline;">
        <input type="hidden" name="id" value={todo.id} />
        <button type="submit" class:completed={todo.completed}>
          {todo.text}
        </button>
      </form>

      <form method="POST" action="?/delete" style="display: inline;">
        <input type="hidden" name="id" value={todo.id} />
        <button type="submit" aria-label="Delete {todo.text}">x</button>
      </form>
    </li>
  {/each}
</ul>

The ?/create, ?/toggle, and ?/delete query parameters tell SvelteKit which named action to invoke. Without a query parameter, the default action runs.

The Action Handler

Every action receives a RequestEvent with full access to the request, cookies, locals, and more.

export const actions: Actions = {
  update: async ({
    request,   // the raw Request — use formData() to read form fields
    locals,    // auth data from hooks
    cookies,   // read/set cookies
    params,    // dynamic route params
    url        // full URL object
  }) => {
    const formData = await request.formData();
    // process the form data...
  }
};

Receiving Form Data

request.formData() returns a FormData object. Use .get() for single values and .getAll() for multi-select or checkbox groups.

export const actions: Actions = {
  default: async ({ request }) => {
    const formData = await request.formData();

    const name = formData.get('name') as string;
    const email = formData.get('email') as string;
    const roles = formData.getAll('roles') as string[];
    const avatar = formData.get('avatar') as File;

    // File uploads work natively
    if (avatar.size > 0) {
      await saveFile(avatar);
    }
  }
};

Returning Errors with fail()

fail() returns data to the page without redirecting. The first argument is the HTTP status code, the second is the data to send back.

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

export const actions: Actions = {
  default: async ({ request }) => {
    const formData = await request.formData();
    const email = formData.get('email') as string;
    const name = formData.get('name') as string;

    const errors: Record<string, string> = {};

    if (!name?.trim()) errors.name = 'Name is required';
    if (!email?.includes('@')) errors.email = 'Invalid email address';

    if (Object.keys(errors).length > 0) {
      return fail(400, { errors, name, email });
    }

    await db.user.create({ data: { name, email } });
  }
};

The returned data is available in the form prop. Returning the submitted values (like name and email) lets you repopulate the form so the user does not have to re-type everything.

Redirecting After Success

After a successful mutation, redirect the user to prevent duplicate submissions on page refresh.

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

export const actions: Actions = {
  default: async ({ request }) => {
    const formData = await request.formData();
    // ... validate and save ...

    // Post/Redirect/Get pattern
    redirect(303, '/success');
  }
};

The 303 status code is important. It tells the browser to follow the redirect with a GET request, preventing the form from being resubmitted if the user refreshes.

Why This Is Better Than Client-Side Form Handling

Traditional SPA form handling requires substantial client-side infrastructure.

SPA form handling:
  1. Prevent default form submission
  2. Read form values from DOM or state
  3. Validate on the client
  4. Send fetch() request to API endpoint
  5. Handle loading state
  6. Handle success (redirect, toast, etc.)
  7. Handle errors (display messages)
  8. Handle network failure
  9. Handle race conditions
  → All of this code ships to the client

SvelteKit form actions:
  1. HTML form submits to the server
  2. Action validates and processes
  3. Return fail() for errors, redirect() for success
  4. Page re-renders with form data
  → Works without JavaScript
  → Zero client-side form logic needed
  → Add use:enhance for better UX with JS

The server action is the single source of truth. Validation happens on the server where you have access to the database. The form works when JavaScript fails to load, when the user has a slow connection, or when a browser extension interferes with scripts.

Common Pitfalls

  • Forgetting the method="POST" attribute: HTML forms default to GET. Without method="POST", the form submission goes to the load function as a navigation, not to the action.
  • Not returning submitted values on failure: When an action returns fail(), the form re-renders. If you do not include the submitted values in the failure response, the user loses everything they typed.
  • Using GET for mutations: Form actions use POST. If you need to change data based on GET parameters, use a load function with url.searchParams instead. Mutations should always use POST.
  • Not using the 303 redirect status: After a successful form submission, always redirect with status 303. This implements the Post/Redirect/Get pattern and prevents duplicate submissions.
  • Putting validation only in the action: Client-side validation via HTML attributes (required, type="email", minlength) gives immediate feedback. Use it alongside server validation, not instead of it.

Key Takeaways

  • Form actions are exported from +page.server.ts as an actions object. The default action handles plain POST submissions; named actions use ?/name query parameters.
  • Actions receive form data via request.formData(), validate it, and either return fail() with errors or call redirect() on success.
  • The form prop in the page component contains whatever the action returned, including error messages and submitted values for repopulating fields.
  • Forms work without JavaScript by default. This is progressive enhancement at its best: the basic functionality is built on the platform, and you layer interactivity on top.
  • The Post/Redirect/Get pattern (redirect with 303 after success) prevents duplicate form submissions and is the standard approach for form actions.