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.
// 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: 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
endDie 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.
<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
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 -->
<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.
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.tsunterexport const actions. request.formData()liest die Felder; validieren ersetzt strong params.fail(400, …)→ Fehler in dieform-Prop;redirect(303, …)→ nach Erfolg.defaultoder benannt (?/create);use:enhancebringt 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.