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.

svelte src/routes/contact/+page.svelte
<form method="POST">
    <input name="name" required />
    <input name="email" type="email" required />
    <textarea name="message" required></textarea>
    <button type="submit">Senden</button>
</form>
ts src/routes/contact/+page.server.ts
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:

  1. Der Nutzer füllt das Formular aus und klickt Senden.
  2. Der Browser submitted klassisch per POST an die aktuelle URL.
  3. SvelteKit erkennt die actions-Definition und ruft die default-Handler auf.
  4. Der Server liest formData, verarbeitet, gibt eine Antwort zurück.
  5. SvelteKit rendert die Seite neu — der Rückgabewert ist als form im 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:

svelte +page.svelte mit Antwort
<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.

ts +page.server.ts mit Validation
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:

svelte +page.svelte mit Fehler-Anzeige
<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:

ts +page.server.ts mit zwei Actions
export const actions = {
    login: async ({ request, cookies }) => {
        // ...
        return { success: true, action: 'login' };
    },

    register: async ({ request }) => {
        // ...
        return { success: true, action: 'register' };
    },
};
svelte +page.svelte mit zwei Forms
<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.

svelte Mit use:enhance
<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 fetch ohne 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:

svelte enhance mit Callback
<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:

svelte Optimistic Update
<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.

Weiterführende Ressourcen

Externe Quellen

Verwandte Artikel

/ Weiter

Zurück zu Forms

Zur Übersicht