SvelteKit Form Actions sind eine der besten Ideen des Frameworks. Du schreibst ein normales HTML-Formular, das per POST an die eigene Route geht. Auf dem Server fängst du den Submit ab, prüfst Daten, schreibst in die Datenbank, gibst eine Antwort zurück. Der Witz: Das funktioniert ohne JavaScript — und mit JavaScript per use:enhance automatisch ohne Page-Reload. Du baust einmal, bekommst beides.
Das einfachste Beispiel
Eine Form-Action besteht aus zwei Dateien: dem Markup und dem Server-Handler.
<form method="POST">
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button type="submit">Senden</button>
</form>export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const name = data.get('name');
const email = data.get('email');
const message = data.get('message');
// ...verarbeiten, z. B. E-Mail senden
return { success: true };
},
};Was hier passiert:
- Der Nutzer füllt das Formular aus und klickt Senden.
- Der Browser submitted klassisch per
POSTan die aktuelle URL. - SvelteKit erkennt die
actions-Definition und ruft diedefault-Handler auf. - Der Server liest
formData, verarbeitet, gibt eine Antwort zurück. - SvelteKit rendert die Seite neu — der Rückgabewert ist als
formim Page-Scope verfügbar.
Ohne eine Zeile clientseitiges JavaScript funktioniert das. Genau das ist Progressive Enhancement: Die Grundfunktion läuft mit reinem HTML, JavaScript verbessert sie nur.
Die Antwort im UI darstellen
SvelteKit reicht den Action-Rückgabewert automatisch als form-Prop an die Seite weiter:
<script>
let { form } = $props();
</script>
<form method="POST">
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button type="submit">Senden</button>
</form>
{#if form?.success}
<p>Danke, deine Nachricht ist angekommen.</p>
{/if}form ist null, bis das Formular abgeschickt wurde. Danach enthält es genau das, was deine Action-Funktion zurückgegeben hat.
Validierung mit fail
Bei einem Validierungs-Fehler willst du nicht Erfolg signalisieren, sondern dem Nutzer den Fehler zurückgeben. Dafür gibt es die fail-Helper-Funktion.
import { fail } from '@sveltejs/kit';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(2, 'Name zu kurz'),
email: z.string().email('Ungültige E-Mail'),
message: z.string().min(10, 'Mindestens 10 Zeichen'),
});
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const input = {
name: data.get('name'),
email: data.get('email'),
message: data.get('message'),
};
const result = schema.safeParse(input);
if (!result.success) {
return fail(400, {
errors: result.error.flatten().fieldErrors,
values: input, // damit der Nutzer nicht alles neu tippen muss
});
}
// erfolgreiche Verarbeitung
return { success: true };
},
};fail(status, data) gibt dem Browser einen HTTP-Statuscode (typisch 400 für Validierungs-Fehler) plus die gleichen Daten, die du sonst als Erfolg zurückgeben würdest. Auf der Seite wertest du sie genauso aus:
<script>
let { form } = $props();
</script>
<form method="POST">
<label>
Name
<input name="name" value={form?.values?.name ?? ''} required />
{#if form?.errors?.name}<small>{form.errors.name[0]}</small>{/if}
</label>
<label>
E-Mail
<input name="email" type="email" value={form?.values?.email ?? ''} required />
{#if form?.errors?.email}<small>{form.errors.email[0]}</small>{/if}
</label>
<label>
Nachricht
<textarea name="message" required>{form?.values?.message ?? ''}</textarea>
{#if form?.errors?.message}<small>{form.errors.message[0]}</small>{/if}
</label>
<button type="submit">Senden</button>
</form>Beim erneuten Submit füllt SvelteKit die Eingabefelder mit den vorigen Werten — der Nutzer korrigiert nur den fehlerhaften Teil, der Rest bleibt erhalten.
Named Actions: mehrere Submits pro Seite
Eine Seite kann mehrere Aktionen anbieten. Statt einer default-Action benennst du sie:
export const actions = {
login: async ({ request, cookies }) => {
// ...
return { success: true, action: 'login' };
},
register: async ({ request }) => {
// ...
return { success: true, action: 'register' };
},
};<form method="POST" action="?/login">
<input name="email" type="email" required />
<input name="password" type="password" required />
<button>Einloggen</button>
</form>
<form method="POST" action="?/register">
<input name="email" type="email" required />
<input name="password" type="password" required />
<input name="passwordConfirm" type="password" required />
<button>Registrieren</button>
</form>Das ?/name-Suffix in action wählt die entsprechende Server-Funktion. Praktisch, wenn auf einer Seite mehrere zusammenhängende Aktionen sind (Login + Registrieren, Edit + Delete, etc.).
Progressive Enhancement mit use:enhance
So weit gut — aber bei jedem Submit reloaded die Seite. Das ist zwar funktionssicher, aber träge. Der nächste Schritt: use:enhance aus $app/forms.
<script>
import { enhance } from '$app/forms';
</script>
<form method="POST" use:enhance>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button>Einloggen</button>
</form>Was passiert mit dieser einen Direktive:
- Der Submit läuft per
fetchohne Page-Reload. - Der Server-Handler läuft trotzdem normal.
- Die
form-Prop aktualisiert sich. - Beim Erfolg invalidet SvelteKit relevante Loader und re-rendered automatisch.
Und ohne JavaScript? Funktioniert exakt gleich, nur mit klassischem Reload. Genau das macht Progressive Enhancement so wertvoll: Du baust einmal, der Code läuft auf jedem Endgerät und in jeder Situation (langsame Verbindung, JavaScript-blockiert).
Custom-Logik beim Submit
enhance akzeptiert einen Callback, mit dem du in den Submit-Lebenszyklus eingreifen kannst:
<script>
import { enhance } from '$app/forms';
let saving = $state(false);
</script>
<form
method="POST"
use:enhance={() => {
saving = true;
return async ({ update }) => {
await update(); // Default-Verhalten ausführen
saving = false;
};
}}
>
<input name="email" type="email" required disabled={saving} />
<button disabled={saving}>
{saving ? 'Sende ...' : 'Einloggen'}
</button>
</form>Die outer Funktion läuft vor dem Submit, die inner Funktion nach der Server-Antwort. Dazwischen kannst du saving = true setzen, danach wieder zurückstellen.
Optimistic Updates mit enhance
Manchmal willst du das UI bevor der Server antwortet aktualisieren — etwa eine neue Nachricht in einem Chat sofort einblenden. Das geht über die Callback-Form:
<script>
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
let { data } = $props();
let optimisticMessages = $state<{ id: string; text: string }[]>([]);
</script>
<ul>
{#each [...data.messages, ...optimisticMessages] as msg (msg.id)}
<li>{msg.text}</li>
{/each}
</ul>
<form
method="POST"
action="?/post"
use:enhance={({ formData }) => {
const text = formData.get('text') as string;
const tempId = crypto.randomUUID();
optimisticMessages.push({ id: tempId, text });
return async ({ update }) => {
await update();
optimisticMessages = optimisticMessages.filter((m) => m.id !== tempId);
};
}}
>
<input name="text" required />
<button>Senden</button>
</form>Sobald der Nutzer abschickt, taucht die Nachricht sofort auf. Wenn die Server-Antwort kommt, wird der „echte” Eintrag aus data.messages geladen, der temporäre verschwindet. Wirkt instantan, auch bei langsamer Verbindung.
Stolperfallen und Tipps
name-Attribut vergessen.
FormData liest nur Felder mit name-Attribut. Ohne den Namen ist das Feld unsichtbar — der Server bekommt nichts.
type="button" für Reset/Cancel-Buttons.
Ein Button im <form> ohne type ist submit — er löst die Action aus. Cancel-Buttons brauchen type="button".
Werte beim Re-Render verloren.
Wer keine value={form?.values?.name} zurückreicht, sieht nach einem Validation-Fail leere Felder. Werte erhalten ist Pflicht-Komfort.
fail nicht zurückgegeben.
Wenn du fail(...) aufrufst, aber nicht zurückgibst, läuft die Funktion weiter und produziert eine andere Antwort. return fail(400, ...) ist Pflicht.
Server-Code in +page.svelte.
Form Actions gehören in +page.server.ts (oder +page.server.js). Code dort läuft nur auf dem Server — Datenbank-Calls, Secrets, etc. sind sicher.
enhance ohne method="POST".
enhance arbeitet nur mit POST-Forms. Ein GET-Form (z. B. eine Search-Bar) braucht andere Mechanik (onsubmit mit Fetch oder direkt goto).
Cookie/Session vergessen zu setzen.
Bei Login-Aktionen: cookies.set('session', ...) aus dem event-Argument. Sonst wird der Login zwar als erfolgreich gemeldet, aber die Session ist nicht persistent.