Zwei Form-Themen, die in fast jeder größeren Anwendung auftauchen — und beide ihre eigenen Stolpersteine haben. File Uploads brauchen das richtige Form-Encoding und auf dem Server eine andere Verarbeitung als normale Felder. Multi-Step-Formulare brauchen eine Strategie für State, der über mehrere Seiten oder Schritte hält. Dieser Artikel zeigt beides als praktische Lösung.

File Upload: das Setup

Damit ein Browser eine Datei mit dem Form-Submit übermittelt, brauchst du zwei Dinge:

  • enctype="multipart/form-data" am <form>. Ohne diese Angabe schickt der Browser nur den Datei-Namen als String.
  • <input type="file" name="..."> im Markup.
svelte Avatar-Upload
<form method="POST" enctype="multipart/form-data">
    <label>
        Profilbild
        <input type="file" name="avatar" accept="image/*" required />
    </label>
    <button>Hochladen</button>
</form>

Auf dem Server liest du die Datei aus den FormData:

ts +page.server.ts
import { fail } from '@sveltejs/kit';

export const actions = {
    default: async ({ request }) => {
        const data = await request.formData();
        const file = data.get('avatar');

        if (!(file instanceof File) || file.size === 0) {
            return fail(400, { error: 'Keine Datei ausgewählt' });
        }

        if (file.size > 2 * 1024 * 1024) {
            return fail(400, { error: 'Datei zu groß (max. 2 MB)' });
        }

        if (!file.type.startsWith('image/')) {
            return fail(400, { error: 'Nur Bilder erlaubt' });
        }

        // Datei lesen und speichern
        const buffer = await file.arrayBuffer();
        // ...z. B. in S3 hochladen oder im Filesystem ablegen

        return { success: true };
    },
};

Wichtig: Der Server muss die Größe und den Typ selbst prüfen. Das accept-Attribut im Markup ist nur ein Hinweis für den Browser-Datei-Picker — manipulierter Client-Code könnte beliebige Dateien hochladen.

Live-Vorschau im Browser

Bevor die Datei zum Server geht, kannst du sie im Browser anzeigen — nützlich für Foto-Uploads, bei denen der Nutzer das Bild vor dem Absenden sehen will.

svelte Mit Vorschau
<script>
    let files = $state<FileList | null>(null);
    let previewUrl = $derived.by(() => {
        if (!files || files.length === 0) return null;
        return URL.createObjectURL(files[0]);
    });

    $effect(() => {
        // Memory-Leak verhindern: Object-URL beim Wechsel freigeben
        return () => {
            if (previewUrl) URL.revokeObjectURL(previewUrl);
        };
    });
</script>

<form method="POST" enctype="multipart/form-data">
    <input type="file" name="avatar" accept="image/*" bind:files />

    {#if previewUrl}
        <img src={previewUrl} alt="Vorschau" style:max-width="200px" />
    {/if}

    <button>Hochladen</button>
</form>

Drei Punkte zu beobachten:

  1. bind:files liefert die ausgewählte Datei als FileList-Objekt.
  2. URL.createObjectURL erzeugt einen temporären Browser-internen Pfad zur Datei. Der <img>-Tag kann diesen Pfad direkt anzeigen.
  3. URL.revokeObjectURL im Cleanup verhindert ein Memory-Leak. Object-URLs werden sonst behalten, bis der Tab geschlossen wird.

Drag-and-Drop für Dateien

Eine Drop-Zone fühlt sich oft natürlicher an als ein File-Picker — vor allem auf Desktop.

svelte Drop-Zone
<script>
    let files = $state<FileList | null>(null);
    let isOver = $state(false);
    let inputElement: HTMLInputElement;

    function handleDrop(event: DragEvent) {
        event.preventDefault();
        isOver = false;
        if (event.dataTransfer?.files) {
            files = event.dataTransfer.files;
            inputElement.files = event.dataTransfer.files;
        }
    }
</script>

<form method="POST" enctype="multipart/form-data">
    <div
        class="dropzone"
        class:over={isOver}
        ondragover={(e) => { e.preventDefault(); isOver = true; }}
        ondragleave={() => isOver = false}
        ondrop={handleDrop}
    >
        <p>Datei hierher ziehen oder</p>
        <label>
            <input type="file" name="upload" bind:this={inputElement} bind:files />
            Datei wählen
        </label>
    </div>

    {#if files && files.length > 0}
        <p>Ausgewählt: {files[0].name}</p>
    {/if}

    <button>Hochladen</button>
</form>

<style>
    .dropzone {
        border: 2px dashed #ccc;
        padding: 2rem;
        text-align: center;
        transition: none;
    }
    .dropzone.over {
        border-color: teal;
        background: #f0fdfd;
    }
</style>

Wichtig dabei: Der Drop füllt nicht nur die files-Variable, sondern auch inputElement.files. Sonst submitted das Formular die Datei nicht — denn der Browser sendet beim klassischen Submit nur das, was im echten <input> steht.

Multiple Files

Mit dem multiple-Attribut können mehrere Dateien gleichzeitig ausgewählt werden:

svelte Mehrere Dateien
<form method="POST" enctype="multipart/form-data">
    <input type="file" name="documents" multiple />
    <button>Hochladen</button>
</form>
ts Server
export const actions = {
    default: async ({ request }) => {
        const data = await request.formData();
        const files = data.getAll('documents'); // Array

        for (const file of files) {
            if (!(file instanceof File)) continue;
            // ...verarbeiten
        }

        return { success: true };
    },
};

Der Unterschied: data.getAll('documents') statt data.get('documents'). Der Server bekommt ein Array.

Multi-Step-Formulare: das Grundproblem

Mehrstufige Formulare haben einen zentralen Stolperstein: Wo lebt der Zwischen-State?

Wenn jeder Schritt eine eigene Seite ist, reset sich der State bei der Navigation. Wenn alles auf einer Seite ist, wird das Markup unübersichtlich. Zwei Patterns, die beide ihren Sweet Spot haben.

Pattern 1 – Eine Seite, mehrere Sektionen

Der einfachste Weg: Alles in einer Komponente, der aktuelle Schritt im State.

svelte Wizard.svelte
<script lang="ts">
    type Data = {
        name: string;
        email: string;
        role: 'admin' | 'user';
        consent: boolean;
    };

    let data = $state<Data>({
        name: '',
        email: '',
        role: 'user',
        consent: false,
    });

    let step = $state(1);
    const totalSteps = 3;

    function next() {
        if (step < totalSteps) step++;
    }

    function back() {
        if (step > 1) step--;
    }

    async function submit() {
        await fetch('/api/register', {
            method: 'POST',
            body: JSON.stringify(data),
        });
    }
</script>

<header>
    <progress value={step} max={totalSteps}></progress>
    <p>Schritt {step} von {totalSteps}</p>
</header>

{#if step === 1}
    <section>
        <h2>Persönliches</h2>
        <input bind:value={data.name} placeholder="Name" />
        <input bind:value={data.email} type="email" placeholder="E-Mail" />
        <button onclick={next}>Weiter</button>
    </section>
{:else if step === 2}
    <section>
        <h2>Rolle</h2>
        <label>
            <input type="radio" bind:group={data.role} value="user" />
            Standard
        </label>
        <label>
            <input type="radio" bind:group={data.role} value="admin" />
            Admin
        </label>
        <button onclick={back}>Zurück</button>
        <button onclick={next}>Weiter</button>
    </section>
{:else}
    <section>
        <h2>Zusammenfassung</h2>
        <p>{data.name} ({data.email}) — {data.role}</p>
        <label>
            <input type="checkbox" bind:checked={data.consent} />
            Ich akzeptiere die AGB
        </label>
        <button onclick={back}>Zurück</button>
        <button onclick={submit} disabled={!data.consent}>Absenden</button>
    </section>
{/if}

Vorteile dieses Ansatzes:

  • State liegt zentral im data-Objekt. Bei jedem Schritt-Wechsel passiert kein Verlust.
  • Schritt-Navigation ist trivial — nur eine step-Variable.
  • Validierung pro Schritt lässt sich leicht ergänzen (if (!data.email) return; vor dem next()).

Nachteile: Eine sehr lange Datei, wenn die Schritte komplex werden. Lösung: Jeden Schritt in eine eigene Komponente auslagern.

Pattern 2 – Echte Routen pro Schritt mit persistentem State

Wenn die einzelnen Schritte komplex sind und eine eigene URL haben sollen (für Lesezeichen, Browser-Zurück-Verhalten, Analytics), ist das Pattern eine Route pro Schritt plus geteilter State.

ts src/lib/state/wizard.svelte.ts
export const wizard = $state<{
    name: string;
    email: string;
    role: 'admin' | 'user';
}>({
    name: '',
    email: '',
    role: 'user',
});
svelte src/routes/register/personal/+page.svelte
<script>
    import { goto } from '$app/navigation';
    import { wizard } from '$lib/state/wizard.svelte';
</script>

<input bind:value={wizard.name} placeholder="Name" />
<input bind:value={wizard.email} type="email" placeholder="E-Mail" />
<button onclick={() => goto('/register/role')}>Weiter</button>
svelte src/routes/register/role/+page.svelte
<script>
    import { goto } from '$app/navigation';
    import { wizard } from '$lib/state/wizard.svelte';
</script>

<label>
    <input type="radio" bind:group={wizard.role} value="user" />
    Standard
</label>
<label>
    <input type="radio" bind:group={wizard.role} value="admin" />
    Admin
</label>

<button onclick={() => goto('/register/personal')}>Zurück</button>
<button onclick={() => goto('/register/summary')}>Weiter</button>

Achtung beim Server-Side-Rendering: Der globale $state-Container in wizard.svelte.ts lebt pro Browser-Sitzung auf dem Client. Beim SSR wäre er pro Request — aber bei einem Wizard ist normalerweise kein SSR pro Schritt nötig, weil die Felder sowieso clientseitig bearbeitet werden.

Wenn du den State über Reload hinweg erhalten willst (Nutzer schließt versehentlich den Tab), brauchst du localStorage oder einen Server-State. Das ist der Bereich, wo eine Library wie sveltekit-superforms oder ein Tanstack-Query-basierter Wizard übernimmt.

Welches Pattern wann?

SituationEmpfehlung
2 bis 4 kurze Schritte ohne URL-AnforderungPattern 1 (eine Komponente)
Schritte sollen als URLs ansteuerbar seinPattern 2 (Routen + globaler State)
Schritte sind sehr komplex und sollen lazy geladen werdenPattern 2
State soll über Reload erhalten bleibenPattern 2 + localStorage
Server-Rendering mit Vorbefüllung pro SchrittPattern 2 + load-Funktion pro Schritt

Häufige Stolperfallen

enctype vergessen. Ohne enctype="multipart/form-data" schickt der Browser nur den Datei-Namen als Text. Die Datei selbst landet nie auf dem Server.

Datei-Größen-Prüfung nur clientseitig. Das accept-Attribut und JavaScript-Checks sind freundlich, aber kein Schutz. Server-seitig die Größe nochmal prüfen.

URL.createObjectURL ohne revokeObjectURL. Jeder Aufruf reserviert Speicher. Bei Bilder-Galerien mit vielen Wechseln summiert sich das.

Bei Drag-and-Drop kein preventDefault auf dragover. Ohne preventDefault interpretiert der Browser den Drop als „Datei in neuem Tab öffnen” — die Drop-Zone funktioniert nicht.

State zwischen Multi-Step-Routen verloren. Wenn jeder Schritt eine eigene Komponente ist, geht lokaler $state beim Routen-Wechsel verloren. Globaler $state-Container in .svelte.ts löst das.

Globaler $state als Sitzungs-State auf dem Server. Auf dem SvelteKit-Server teilen sich alle Requests denselben Modul-Speicher. Globaler State auf dem Server ist gefährlich — pro-Request-State gehört in event.locals. Bei Wizards meistens kein Problem, weil sie clientseitig laufen.

Weiterführende Ressourcen

Externe Quellen

Verwandte Artikel

/ Weiter

Zurück zu Forms

Zur Übersicht