S SvelteKurs
M9 · Laden & Mutieren

Form Actions

Daten mutieren, ohne selbst eine API zu bauen: Formulare posten direkt an server-seitige Actions — mit Validierung, Redirects und progressiver Verbesserung.

Das Controller-Gegenstück von SvelteKit

Wenn load das Lesen ist, sind Form Actions das Schreiben. Sie leben in der +page.server.ts unter dem Export actions und nehmen ein normales <form method="POST"> entgegen. Du bekommst die Formulardaten über request.formData(), validierst sie, schreibst in die Datenbank und antwortest — ganz ohne eigene JSON-API.

+page.server.ts ts
// src/routes/kontakt/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';

export const actions: Actions = {
  default: async ({ request }) => {
    const data = await request.formData();
    const name = String(data.get('name') ?? '').trim();

    if (!name) return fail(400, { error: 'Name fehlt', name });

    // ... speichern ...
    throw redirect(303, '/danke');
  }
};

🛤️ Rails-Vergleich: Form Action ≈ create/update + strong params

Eine Form Action ist praktisch eine create/update-Action deines Controllers. request.formData() ist dein Zugriff auf die Parameter — und weil du jeden Wert einzeln herausziehst und validierst, ersetzt das die strong params aus Rails. fail(400, …) entspricht render :new, status: :unprocessable_entity, und redirect(303, …) ist dein redirect_to nach erfolgreichem Speichern.

Rails ruby
# Rails: create/update-Action + strong params
class NotesController < ApplicationController
  before_action :authenticate_user!

  def create
    note = current_user.notes.build(note_params)
    if note.save
      redirect_to notes_path, notice: 'Gespeichert'
    else
      render :new, status: :unprocessable_entity   # ~ fail(400, …)
    end
  end

  def destroy
    current_user.notes.find(params[:id]).destroy   # Ownership über Scope
    redirect_to notes_path
  end

  private

  def note_params
    params.require(:note).permit(:title, :tag)      # ~ formData().get(...)
  end
end

Die form-Prop, fail und redirect

Was eine Action zurückgibt (statt zu redirecten) landet in der Komponente als form-Prop. So zeigst du Fehlermeldungen an und füllst Felder nach einem Fehlschlag wieder vor. Nutze fail(status, daten) für Validierungsfehler (die Seite bleibt, form enthält deine Daten) und redirect(303, ziel) nach Erfolg.

+page.svelte svelte
<script lang="ts">
  import type { PageData, ActionData } from './$types';
  let { data, form }: { data: PageData; form: ActionData } = $props();
</script>

<form method="POST">
  <input name="name" value={form?.name ?? ''} />
  {#if form?.error}<p class="error">{form.error}</p>{/if}
  <button>Senden</button>
</form>

💡 Warum 303?

Nach einem erfolgreichen POST leitet man mit 303 See Other auf eine GET-Seite um (Post/Redirect/Get). Das verhindert, dass ein Neuladen die Mutation erneut auslöst — genau wie redirect_to in Rails.

Benannte Actions: ?/create und ?/delete

Eine Seite braucht oft mehrere Aktionen. Statt default vergibst du dann Namen — und das Formular wählt per action="?/name" die passende aus. Genau so funktioniert die echte DevNotes-App in diesem Projekt: eine Action zum Anlegen, eine zum Löschen.

src/routes/devnotes/+page.server.ts ts
// src/routes/devnotes/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import { and, eq } from 'drizzle-orm';
import { db, schema } from '$lib/server/db';
import type { Actions } from './$types';

export const actions: Actions = {
  create: async ({ request, locals }) => {
    if (!locals.user) throw redirect(303, '/login');
    const data = await request.formData();
    const title = String(data.get('title') ?? '').trim();
    const tag = String(data.get('tag') ?? 'allgemein').trim() || 'allgemein';

    if (!title) return fail(400, { error: 'Titel darf nicht leer sein.' });

    await db.insert(schema.notes).values({ userId: locals.user.id, title, tag });
    return { success: true };
  },

  delete: async ({ request, locals }) => {
    if (!locals.user) throw redirect(303, '/login');
    const data = await request.formData();
    const id = Number(data.get('id'));
    // Ownership wird in der WHERE-Klausel erzwungen — man löscht nur Eigenes.
    await db
      .delete(schema.notes)
      .where(and(eq(schema.notes.id, id), eq(schema.notes.userId, locals.user.id)));
    return { success: true };
  }
};

Und das dazugehörige Markup mit den beiden Formularen:

src/routes/devnotes/+page.svelte svelte
<!-- src/routes/devnotes/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  let { data, form } = $props();
</script>

<!-- Neue Notiz anlegen: ruft die benannte Action ?/create auf -->
<form method="POST" action="?/create" use:enhance>
  <input name="title" placeholder="Neue Notiz…" required />
  <input name="tag" placeholder="Tag (optional)" />
  <button>Anlegen</button>
</form>

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

<!-- Löschen: benannte Action ?/delete mit verstecktem id-Feld -->
{#each data.notes as note (note.id)}
  <form method="POST" action="?/delete" use:enhance>
    <input type="hidden" name="id" value={note.id} />
    <button>Löschen</button>
  </form>
{/each}

ℹ️ Ownership statt Zufall

Beachte im delete: Die Zugehörigkeit wird in der WHERE-Klausel erzwungen (userId === locals.user.id) — man kann nur eigene Notizen löschen. Das ist das Äquivalent zu current_user.notes.find(...) in Rails, wo der Scope die Sicherheit übernimmt.

Progressive Enhancement mit use:enhance

Das Beste zum Schluss: Alles oben funktioniert ohne JavaScript — ein klassischer Full-Page-POST. Fügst du use:enhance hinzu, fängt SvelteKit das Absenden im Browser ab, schickt es per fetch, aktualisiert die form-Prop und lädt die zuständigen load-Funktionen automatisch neu — ohne vollständigen Seiten-Reload. Ist JS deaktiviert, greift nahtlos das native Formularverhalten.

use:enhance svelte
import { enhance } from '$app/forms';

<form method="POST" action="?/create" use:enhance>
  <input name="title" required />
  <button>Anlegen</button>
</form>

💡 Genau das macht DevNotes

In der echten App tragen alle drei Formulare (create, delete, logout) ein use:enhance. Deshalb fühlt sich die Notiz-Liste wie eine SPA an, funktioniert aber auch komplett ohne Client-JS.

Zusammenfassung

💡 Das Wichtigste

  • Actions leben in +page.server.ts unter export const actions.
  • request.formData() liest die Felder; validieren ersetzt strong params.
  • fail(400, …) → Fehler in die form-Prop; redirect(303, …) → nach Erfolg.
  • default oder benannt (?/create); use:enhance bringt progressive Verbesserung.

🎯 Probier es selbst

Baue die DevNotes-Löschfunktion nach: ein <form method="POST" action="?/delete"> mit einem <input type="hidden" name="id"> und einer Action, die per Number(data.get('id')) die richtige Zeile trifft. Ergänze danach ein use:enhance und beobachte, dass die Liste ohne Reload aktualisiert.