# So funktioniert das Absenden eines HTML-Formulars

> Was ein Browser beim Absenden eines Formulars tut: welche Felder er sendet, wie er sie kodiert, was der Server antworten sollte und wie Ikiform antwortet.

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/html-form-submission

Wenn jemand auf Absenden drückt, prüft der Browser die Constraints des Formulars, sammelt jedes benannte und aktivierte Steuerelement, kodiert sie mit dem `enctype` des Formulars und sendet sie mit der `method` des Formulars an die `action`-URL. Der Server antwortet wie bei jedem Seitenaufruf, und ein sauber gebauter Server beantwortet einen erfolgreichen POST mit einer `303`-Weiterleitung, damit ein Neuladen das Formular nicht doppelt sendet. Richte `action` auf ein Formular-Backend wie Ikiform aus, und du bekommst diese Weiterleitung, Feldvalidierung und Spamfilterung ganz ohne eigenen Server.

HTML-Formulare sind älter als JavaScript und immer noch der zuverlässigste Weg, Daten von einer Webseite zu senden. Sie funktionieren ohne Skripte, auf langsamen Handys und in jedem Baukasten, in den du Markup einfügen kannst. Die meisten Formular-Bugs entstehen, weil man nicht weiß, was der Browser von sich aus tut. Deshalb geht diese Anleitung ein Absenden Schritt für Schritt durch und zeigt dann, wie ein Formular-Backend dazu passt.

## Was passiert, wenn du auf Absenden drückst

Ein Absenden ist eine normale Navigation mit angehängtem Body. Der Browser erledigt vier Dinge, bevor irgendetwas die Seite verlässt, übergibt dann den Request ans Netzwerk und lädt, was zurückkommt.

Diagramm: Was ein Browser zwischen dem Klick auf Absenden und der nächsten Seite tut

1. Ein Absende-Button, die Enter-Taste in einem Textfeld oder form.requestSubmit() startet das Absenden und löst ein submit-Event aus.
2. Sofern das Formular kein novalidate hat, prüft der Browser die Constraints jedes Steuerelements. Das erste ungültige bekommt eine Meldung, und nichts wird gesendet.
3. Der Browser baut den Formulardatensatz aus jedem Steuerelement, das einen name hat und nicht deaktiviert ist.
4. Er kodiert diesen Datensatz mit dem enctype des Formulars, standardmäßig urlencoded.
5. Er navigiert mit der method des Formulars zur action-URL und sendet den Body mit.
6. Der Server antwortet. Ein 303 weist den Browser an, eine andere URL per GET zu laden, und das ist die Seite, auf der der Besucher landet.

Alles oberhalb der Linie passiert ganz ohne Netzwerk. Ein Listener für das submit-Event kann den Ablauf beim ersten Schritt stoppen und mit fetch übernehmen.

## Die Attribute, die den Request bestimmen

Fünf Attribute an `<form>` bestimmen, wohin die Daten gehen und wie. Jedes lässt sich für einen einzelnen Button mit einem passenden `form*`-Attribut überschreiben. So kann ein Formular Buttons für **Send** und **Save draft** haben, die sich unterschiedlich verhalten.

| Attribut | Standard | Was es tut | Überschreiben am Button |
| --- | --- | --- | --- |
| `action` | Die eigene URL der Seite | Die URL, die die Daten empfängt. Relative Werte werden relativ zur Seite aufgelöst. Siehe [das action-Attribut](https://ikiform.com/de/guides/html-form-submission/action-attribute.md) | `formaction` |
| `method` | `get` | `get` packt die Felder in den Query-String der URL. `post` sendet sie als Body. `dialog` schließt ein `<dialog>`, ohne etwas zu senden | `formmethod` |
| `enctype` | `application/x-www-form-urlencoded` | Wie ein `post`-Body kodiert wird. `multipart/form-data` ist für Dateien nötig. `text/plain` ist zum Debuggen gedacht | `formenctype` |
| `novalidate` | Aus | Überspringt beim Absenden die Constraint-Prüfung des Browsers | `formnovalidate` |
| `target` | `_self` | Wo die Antwort geladen wird. `_blank` öffnet einen neuen Tab, ein benanntes iframe lässt die Seite, wo sie ist | `formtarget` |

Zwei Buttons, zwei Verhaltensweisen. Der zweite überspringt die Validierung, und `name` und `value` des geklickten Buttons werden den Daten hinzugefügt, damit der Server weiß, welcher gedrückt wurde:

`Zwei Absende-Buttons`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <input name="email" type="email" required />
  <textarea name="details" required></textarea>
  <button name="intent" value="report">Send report</button>
  <button name="intent" value="draft" formnovalidate>Save as draft</button>
</form>
```

### Welche Steuerelemente gesendet werden

Browser senden nur, was die HTML-Spezifikation den Formulardatensatz nennt. Die meisten Überraschungen in einem Einsendungsprotokoll kommen von diesen Regeln:

- Ein Steuerelement braucht einen `name`. Eine `id` allein sendet nichts.
- `disabled`-Steuerelemente werden übersprungen. Nutze `readonly`, wenn ein Wert angezeigt, gesperrt und gesendet werden soll.
- Eine nicht angehakte Checkbox oder ein nicht gewählter Radio-Button sendet gar nichts, nicht `false`. Eine angehakte Checkbox ohne `value` sendet `on`.
- Nur der Button, der das Formular abgesendet hat, wird mitgesendet, mit seinem `name` und `value`.
- `<select multiple>` und Checkbox-Gruppen mit gemeinsamem Namen senden ein Paar pro ausgewähltem Wert.
- Eingabefelder außerhalb des `<form>`-Elements können sich ihm mit einem `form`-Attribut anschließen, das die `id` des Formulars nennt.

### Wie der Body kodiert wird

Mit dem Standard-`enctype` ist der Body eine Zeile aus `name=value`-Paaren, verbunden mit `&`, Leerzeichen als `+` und allem außerhalb eines sicheren Zeichensatzes prozentkodiert. Das ist der echte Request für das Meldeformular weiter unten, so wie der Browser ihn sendet:

`application/x-www-form-urlencoded`:

```http
POST /f/frm_xxxxxxxxxxxxxxxx HTTP/1.1
Host: ikiform.com
Content-Type: application/x-www-form-urlencoded
Origin: https://example.com
Sec-Fetch-Mode: navigate

email=ada%40example.com&page=https%3A%2F%2Fexample.com%2Fpricing&severity=broken&details=The+annual+toggle+does+nothing&_redirect=https%3A%2F%2Fexample.com%2Freport-sent&_gotcha=
```

`multipart/form-data` verpackt jedes Feld in einen eigenen Teil mit einem Boundary-String, den der Browser wählt. Das ist größer, kann aber Dateien transportieren. Ikiform liest beides. Bei `text/plain` lässt sich nicht zuverlässig erkennen, wo ein Wert endet, deshalb lehnt Ikiform es mit `415 unsupported_media_type` ab.

`multipart/form-data`:

```http
POST /f/frm_xxxxxxxxxxxxxxxx HTTP/1.1
Host: ikiform.com
Content-Type: multipart/form-data; boundary=----formdata7f3a

------formdata7f3a
Content-Disposition: form-data; name="email"

ada@example.com
------formdata7f3a
Content-Disposition: form-data; name="severity"

broken
------formdata7f3a--
```

> Egal welche Kodierung: Halte die Seite in UTF-8. Ikiform dekodiert jeden Body als UTF-8 und lehnt andere Zeichensätze mit `415 unsupported_charset` ab. Datei-Uploads werden noch nicht unterstützt. Ein Post mit einer Datei bekommt `422 files_not_supported`.

## Vier Wege, ein Formular abzusenden

Ein einfacher Post ist nur eine Option. Jeder der folgenden Ansätze ist irgendwo eine gute Wahl; was sich ändert, ist, wer die Antwort verarbeitet und was ohne Skripte passiert.

| Ansatz | Stark bei | Nachteile |
| --- | --- | --- |
| Einfaches `method="post"` mit Weiterleitung | Funktioniert ohne JavaScript, in jedem Baukasten. Der Browser kümmert sich um Ladezustand, Zurück und Neuladen | Ein vollständiger Seitenaufruf. Feldfehler brauchen eine serverseitig gerenderte Seite oder die Fehlerseite des Backends |
| `fetch` mit `FormData` | Kein Neuladen. Du entscheidest, wie Erfolg und jeder Feldfehler aussehen | Ladezustand, Netzwerkfehler und Fokus liegen bei dir. Ohne Sorgfalt bricht es, wenn Skripte aus sind. Siehe [ohne Neuladen der Seite](https://ikiform.com/de/guides/html-form-submission/without-page-reload.md) |
| Form Actions von Frameworks, etwa Server Actions in Next.js oder Form Actions in SvelteKit | Dein Server sieht den Post zuerst, und dasselbe Formular funktioniert vor und nach der Hydration | Du brauchst eine Server-Runtime, und der Request ans Backend geht von deinem Server aus, was Rate-Limits und Origin-Prüfungen verändert |
| `method="get"` | Such- und Filterformulare. Das Ergebnis hat eine teilbare URL | Felder landen in URL, Verlauf und Serverlogs. Nie für personenbezogene Daten oder alles, was Zustand ändert |

Fang mit dem einfachen Post an und setze `fetch` obendrauf. So funktioniert das Formular weiter, wenn ein Skript nicht lädt, und das passiert öfter, als die meisten Analytics zeigen.

## Was der Server antworten sollte

Der Browser behandelt die Antwort auf einen POST als neue Seite. Ist diese Seite die Antwort selbst, mit Status `200`, merkt sich der Browser, dass sie von einem POST stammt. Lädst du neu, bietet er an, das Formular erneut zu senden; bei Zurück und Vor tun manche Browser dasselbe. So zahlen Leute doppelt, und der Support bekommt dieselbe Nachricht dreimal.

Die Lösung ist das Post/Redirect/Get-Muster. Der Server verarbeitet den POST und antwortet dann mit `303 See Other` und einem `Location`-Header. Der Browser lädt diese URL per GET, und die Seite, die der Besucher sieht, stammt von einem GET, also ist Neuladen harmlos.

Diagramm: Einen POST mit einer Seite beantworten im Vergleich zu einer 303-Weiterleitung

1. Bei einer 200-Antwort sendet der Browser das Formular und zeigt den Response-Body als Seite an.
2. Diese Seite ist an den POST gebunden, also fragt ein Neuladen nach erneutem Senden, und eine Bestätigung sendet die Daten noch einmal.
3. Bei einer 303-Antwort sendet der Browser das Formular und wird angewiesen, eine andere URL zu laden.
4. Er lädt diese URL per GET. Ein Neuladen wiederholt nur den GET, also wird nichts doppelt gesendet.

Ikiform beantwortet einen Browser-Post immer mit einem 303, dein Formular bekommt dieses Verhalten also gratis.

| Status | Was der Browser als Nächstes tut | Einsatz |
| --- | --- | --- |
| `303 See Other` | GET auf die `Location`, Body verworfen | Nach jedem erfolgreichen Formular-Post |
| `302 Found` | Browser wechseln in der Praxis zu GET, aber die Spezifikation erlaubt, die Methode beizubehalten | Ältere Server. Besser `303` |
| `307` und `308` | Denselben POST mit demselben Body an der neuen URL wiederholen | Einen Endpunkt verschieben, nie eine Dankeseite |
| `200` mit HTML | Seite anzeigen; Neuladen bietet erneutes Senden an | Formular mit Fehlern erneut anzeigen |
| `4xx` mit HTML | Fehlerseite anzeigen | Fehler, die der Besucher per Zurück beheben kann |

`Post/Redirect/Get`:

```http
HTTP/1.1 303 See Other
Location: https://example.com/report-sent
X-Request-Id: req_01k7v2c9t3e8m5q4x6z0a1b2c3

GET /report-sent HTTP/1.1
Host: example.com
```

## Wie Ikiform antwortet

Ein Endpunkt, `https://ikiform.com/f/<formId>`, antwortet Browsern und Code. Er entscheidet pro Request, ob er mit Seiten oder mit JSON antwortet, anhand von Headern, die der Browser selbst setzt:

| Request | Antwort |
| --- | --- |
| `Accept` enthält `application/json` | JSON |
| Der Body ist JSON | JSON |
| `Sec-Fetch-Mode: navigate`, also jeder einfache Formular-Post | Weiterleitung oder gehostete Seite |
| Jeder andere `Sec-Fetch-Mode`, etwa `cors` von `fetch` | JSON |
| Kein `Sec-Fetch-Mode`, und `Accept` enthält `text/html` | Weiterleitung oder gehostete Seite |
| Alles andere, etwa curl | JSON |

`Sec-Fetch-Mode` ist ein Header, den der Browser setzt und den Skripte auf der Seite nicht ändern können. Ein Formular-Post, der im Tab navigiert, sendet `navigate`; ein `fetch`-Aufruf sendet bei einem Cross-Site-Request `cors`. Dasselbe Formular bekommt also eine Weiterleitung, wenn es auf die einfache Art sendet, und JSON, wenn dein Skript es sendet, ohne dass du etwas umschalten musst. Die vollständige Tabelle steht unter [JSON oder eine Weiterleitung](https://ikiform.com/de/docs/how-it-works.md#response-modes).

Diagramm: Derselbe Ikiform-Endpunkt beantwortet einen einfachen Formular-Post und einen fetch-Aufruf

1. Ein einfacher Formular-Post navigiert im Tab, also sendet der Browser Sec-Fetch-Mode: navigate.
2. Ikiform antwortet im Seitenmodus: mit einer 303-Weiterleitung bei Erfolg oder einer gehosteten Fehlerseite mit dem echten Status des Fehlers.
3. Ein fetch-Aufruf sendet Sec-Fetch-Mode: cors, oder dein Code fordert über den Accept-Header JSON an.
4. Ikiform antwortet im JSON-Modus: 201 mit der ID der Einsendung oder dem Fehler-Envelope mit Feldfehlern nach Feld-ID.

Du konfigurierst den Modus nicht. Er ergibt sich daraus, wie der Request gestellt wurde.

### Wo ein erfolgreicher Post landet

Ein erfolgreicher Browser-Post bekommt einen `303`. Ikiform wählt das Ziel in dieser Reihenfolge:

1. Die Einstellung **Redirect after submit** (Weiterleitung nach dem Absenden) des Formulars im Dashboard. Jede `http:`- oder `https:`-URL funktioniert, weil nur dein Workspace sie setzen kann.
2. Ein `_redirect`-Feld im Post, oder `_next`, wenn es kein `_redirect` gibt, sofern es eine `https:`-URL ohne Benutzername, Passwort oder eigenen Port ist und auf einer der **Allowed domains** des Formulars oder einer Subdomain davon liegt.
3. Andernfalls die gehostete Dankeseite unter `/f/<formId>/thanks`. Kam der Post von einer `https:`-Seite auf einer erlaubten Domain, verlinkt sie dorthin zurück.

Ein `_redirect`, das gegen eine Regel verstößt, wird ignoriert, nie als Fehler behandelt, und ein Formular ohne erlaubte Domains ignoriert `_redirect` komplett. So kann niemand dein Formular nutzen, um Besucher auf eine Site seiner Wahl umzuleiten. [Weiterleitung nach dem Absenden](https://ikiform.com/de/guides/html-form-submission/redirect-after-submit.md) behandelt Dankeseiten ausführlich, und die vollständigen Regeln stehen unter [HTML-Formulare](https://ikiform.com/de/docs/html.md#redirect-rules).

### Wenn ein Post abgelehnt wird

Im Seitenmodus zeigt ein abgelehnter Post eine gehostete Fehlerseite mit dem echten HTTP-Status des Fehlers. Bei einem Validierungsfehler listet sie jedes Problem unter dem Label deines Felds auf und verlinkt auf die Seite, von der der Post kam. Der **Back**-Button des Browsers führt meist zurück zum Formular, mit dem, was der Besucher eingetippt hat; der Link lädt die Seite neu. Im JSON-Modus kommt derselbe Fehler als ein Envelope zurück. Jeder Code steht unter [Fehler](https://ikiform.com/de/docs/errors.md).

`422 im JSON-Modus`:

```json
{
  "ok": false,
  "error": {
    "code": "validation_failed",
    "message": "Some fields are invalid.",
    "fields": {
      "page": ["Enter a valid URL"],
      "details": ["Must be at least 20 characters"]
    }
  },
  "request_id": "req_01k7v2c9t3e8m5q4x6z0a1b2c3"
}
```

Spam ist das Einzige, das nie wie ein Fehler aussieht. Ein Post, der als Spam bewertet wird, bekommt denselben `303` oder `201` wie ein echter, damit ein Bot nichts lernt, und er löst keine E-Mail und keinen Webhook aus.

## Das Formular in deinem Stack

Hier ist ein echtes Formular, eine Meldung für eine kaputte Seite, in jedem Stack. Wähle deinen; die Auswahl gilt für jedes Codebeispiel in diesen Anleitungen. Die Felddefinition unten ist dieselbe, die Ikiform auf dem Server durchsetzt, also probier es aus: Halte die Details kurz oder gib `example` als Adresse ein, und du bekommst den `422`, den dein Formular bekäme.

Formular: Report a broken page

- **Deine E-Mail** (`email`, email, Pflichtfeld)
- **Seitenadresse** (`page`, url, Pflichtfeld)
- **Wie schlimm ist es** (`severity`, select, Pflichtfeld)
- **Was ist passiert** (`details`, textarea, Pflichtfeld)

Füge diese Definition im Tab **Fields** (Felder) des Formulars im Dashboard ein, und Ikiform validiert jeden Post dagegen. Siehe [Felder und Validierung](https://ikiform.com/de/docs/fields.md).

**HTML**

Ganz ohne Skript. Der Browser prüft `required`, `type` und `minlength`, sendet die Felder als urlencoded Text, folgt Ikiforms `303` zu deiner `_redirect`-Seite und zeigt Ikiforms Fehlerseite, wenn der Server den Post ablehnt.

`report.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="email">Your email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="page">Page address</label>
  <input id="page" name="page" type="url" required placeholder="https://example.com/pricing" />

  <label for="severity">How bad is it</label>
  <select id="severity" name="severity">
    <option value="cosmetic">Looks wrong</option>
    <option value="broken">Something doesn't work</option>
    <option value="blocking">I can't continue</option>
  </select>

  <label for="details">What happened</label>
  <textarea id="details" name="details" rows="5" minlength="20" maxlength="4000" required></textarea>

  <input type="hidden" name="_redirect" value="https://example.com/report-sent" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send report</button>
</form>
```

**Next.js**

Eine Server Action empfängt die `FormData`, leitet sie mit deinem Secret Key als JSON weiter und gibt Ikiforms Feldfehler zurück. `useActionState` setzt jede Meldung unter ihr Eingabefeld und behält, was der Besucher eingetippt hat.

`app/report/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";

export interface ReportState {
  sent: boolean;
  fields: Record<string, string[]>;
  message: string;
  values: Record<string, string>;
}

const NAMES = ["email", "page", "severity", "details"] as const;

export async function sendReport(_previous: ReportState, formData: FormData): Promise<ReportState> {
  const values = Object.fromEntries(NAMES.map((name) => [name, String(formData.get(name) ?? "").trim()]));
  const origin = (await headers()).get("origin");

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({ ...values, _gotcha: String(formData.get("_gotcha") ?? "") }),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) return { sent: true, fields: {}, message: "Thanks. We'll look at that page today.", values: {} };
  return {
    sent: false,
    fields: result?.error?.fields ?? {},
    message: result?.error?.message ?? "The report didn't go through. Try again.",
    values,
  };
}
```

`app/report/report-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type ReportState, sendReport } from "./actions";

const initial: ReportState = { sent: false, fields: {}, message: "", values: {} };

function FieldError({ id, messages }: { id: string; messages?: string[] }) {
  return messages?.length ? <p id={`${id}-error`}>{messages[0]}</p> : null;
}

export function ReportForm() {
  const [state, action, pending] = useActionState(sendReport, initial);

  if (state.sent) return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="email">Your email</label>
      <input id="email" name="email" type="email" required defaultValue={state.values.email}
        aria-invalid={Boolean(state.fields.email)} aria-describedby="email-error" />
      <FieldError id="email" messages={state.fields.email} />

      <label htmlFor="page">Page address</label>
      <input id="page" name="page" type="url" required defaultValue={state.values.page}
        aria-invalid={Boolean(state.fields.page)} aria-describedby="page-error" />
      <FieldError id="page" messages={state.fields.page} />

      <label htmlFor="severity">How bad is it</label>
      <select id="severity" name="severity" defaultValue={state.values.severity ?? "broken"}>
        <option value="cosmetic">Looks wrong</option>
        <option value="broken">Something doesn't work</option>
        <option value="blocking">I can't continue</option>
      </select>

      <label htmlFor="details">What happened</label>
      <textarea id="details" name="details" rows={5} required minLength={20} defaultValue={state.values.details}
        aria-invalid={Boolean(state.fields.details)} aria-describedby="details-error" />
      <FieldError id="details" messages={state.fields.details} />

      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.message ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send report"}</button>
    </form>
  );
}
```

**Astro**

Mit `accept: "form"` lehnt Zod eine fehlerhafte E-Mail oder URL ab, bevor etwas deinen Server verlässt, und `isInputError` stellt diese Meldungen pro Feld bereit. Ikiforms eigene `422`-Feldfehler kommen in derselben Form als Action-Daten zurück.

`src/actions/index.ts`:

```ts
import { defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";

export const server = {
  reportPage: defineAction({
    accept: "form",
    input: z.object({
      email: z.email(),
      page: z.url(),
      severity: z.enum(["cosmetic", "broken", "blocking"]),
      details: z.string().min(20, "Tell us a little more, at least 20 characters."),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify(input),
      });
      const result = await response.json();
      if (result.ok) return { sent: true, fields: {} as Record<string, string[]> };
      return { sent: false, fields: (result.error.fields ?? {}) as Record<string, string[]> };
    },
  }),
};
```

`src/pages/report.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";

export const prerender = false;

const result = Astro.getActionResult(actions.reportPage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : (result?.data?.fields ?? {});
---

{result?.data?.sent ? (
  <p role="status">Thanks. We'll look at that page today.</p>
) : (
  <form method="POST" action={actions.reportPage}>
    <label for="email">Your email</label>
    <input id="email" name="email" type="email" required />
    {fields.email && <p>{fields.email[0]}</p>}

    <label for="page">Page address</label>
    <input id="page" name="page" type="url" required />
    {fields.page && <p>{fields.page[0]}</p>}

    <label for="severity">How bad is it</label>
    <select id="severity" name="severity">
      <option value="cosmetic">Looks wrong</option>
      <option value="broken" selected>Something doesn't work</option>
      <option value="blocking">I can't continue</option>
    </select>

    <label for="details">What happened</label>
    <textarea id="details" name="details" rows="5" minlength="20" required></textarea>
    {fields.details && <p>{fields.details[0]}</p>}

    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button>Send report</button>
  </form>
)}
```

**TanStack Start**

Eine POST-Server-Function liest die `FormData` in `.validator()` und gibt die Feldfehler als einfache Daten zurück, sodass die Komponente sie rendern kann, ohne etwas abzufangen.

`src/routes/report.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";

type Outcome = { sent: boolean; fields: Record<string, string[]>; message: string };

const sendReport = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    const read = (name: string) => String(data.get(name) ?? "").trim();
    return {
      email: read("email"),
      page: read("page"),
      severity: read("severity"),
      details: read("details"),
      _gotcha: read("_gotcha"),
    };
  })
  .handler(async ({ data }): Promise<Outcome> => {
    const origin = getRequestHeader("origin");
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        ...(origin ? { Origin: origin } : {}),
      },
      body: JSON.stringify(data),
    });
    const result = await response.json();
    if (result.ok) return { sent: true, fields: {}, message: "Thanks. We'll look at that page today." };
    return { sent: false, fields: result.error.fields ?? {}, message: String(result.error.message) };
  });

export const Route = createFileRoute("/report")({ component: Report });

function Report() {
  const send = useServerFn(sendReport);
  const [outcome, setOutcome] = useState<Outcome | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    try {
      setOutcome(await send({ data: new FormData(event.currentTarget) }));
    } catch {
      setOutcome({ sent: false, fields: {}, message: "The report didn't go through. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (outcome?.sent) return <p role="status">{outcome.message}</p>;
  const fields = outcome?.fields ?? {};

  return (
    <form method="post" onSubmit={onSubmit}>
      <label htmlFor="email">Your email</label>
      <input id="email" name="email" type="email" required aria-invalid={Boolean(fields.email)} />
      {fields.email ? <p>{fields.email[0]}</p> : null}
      <label htmlFor="page">Page address</label>
      <input id="page" name="page" type="url" required aria-invalid={Boolean(fields.page)} />
      {fields.page ? <p>{fields.page[0]}</p> : null}
      <label htmlFor="severity">How bad is it</label>
      <select id="severity" name="severity" defaultValue="broken">
        <option value="cosmetic">Looks wrong</option>
        <option value="broken">Something doesn't work</option>
        <option value="blocking">I can't continue</option>
      </select>
      <label htmlFor="details">What happened</label>
      <textarea id="details" name="details" rows={5} minLength={20} required aria-invalid={Boolean(fields.details)} />
      {fields.details ? <p>{fields.details[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {outcome?.message ? <p role="alert">{outcome.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send report"}</button>
    </form>
  );
}
```

**React Router**

Die `action` der Route ist das echte Ziel des Formulars, also funktioniert `<Form>` auch vor der Hydration. `data()` reicht Ikiforms `422`-Status mit den Feldfehlern und den eingegebenen Werten durch.

`app/routes/report.tsx`:

```tsx
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/report";

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const values = {
    email: String(form.get("email") ?? "").trim(),
    page: String(form.get("page") ?? "").trim(),
    severity: String(form.get("severity") ?? ""),
    details: String(form.get("details") ?? "").trim(),
  };
  const origin = request.headers.get("origin");

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({ ...values, _gotcha: String(form.get("_gotcha") ?? "") }),
  });
  const result = await response.json();
  if (result.ok) return { sent: true as const };
  return data(
    {
      sent: false as const,
      fields: (result.error.fields ?? {}) as Record<string, string[]>,
      message: String(result.error.message),
      values,
    },
    { status: response.status },
  );
}

export default function Report({ actionData }: Route.ComponentProps) {
  const sending = useNavigation().state === "submitting";

  if (actionData?.sent) return <p role="status">Thanks. We'll look at that page today.</p>;
  const fields = actionData?.fields ?? {};
  const values = actionData?.values;

  return (
    <Form method="post">
      <label htmlFor="email">Your email</label>
      <input id="email" name="email" type="email" required defaultValue={values?.email} />
      {fields.email ? <p>{fields.email[0]}</p> : null}
      <label htmlFor="page">Page address</label>
      <input id="page" name="page" type="url" required defaultValue={values?.page} />
      {fields.page ? <p>{fields.page[0]}</p> : null}
      <label htmlFor="severity">How bad is it</label>
      <select id="severity" name="severity" defaultValue={values?.severity ?? "broken"}>
        <option value="cosmetic">Looks wrong</option>
        <option value="broken">Something doesn't work</option>
        <option value="blocking">I can't continue</option>
      </select>
      <label htmlFor="details">What happened</label>
      <textarea id="details" name="details" rows={5} minLength={20} required defaultValue={values?.details} />
      {fields.details ? <p>{fields.details[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {actionData && !Object.keys(fields).length ? <p role="alert">{actionData.message}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send report"}</button>
    </Form>
  );
}
```

**SvelteKit**

Die Default-Form-Action gibt `fail()` mit Ikiforms Status und Feldfehlern zurück. Ohne JavaScript wird die Seite mit ihnen neu gerendert; mit `use:enhance` aktualisiert sie sich an Ort und Stelle. Deklariere `IKIFORM_SECRET_KEY` in `src/env.ts` mit `defineEnvVars`, damit `$app/env/private` ihn lesen kann.

`src/routes/report/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, fetch, url }) => {
    const form = await request.formData();
    const values = {
      email: String(form.get("email") ?? "").trim(),
      page: String(form.get("page") ?? "").trim(),
      severity: String(form.get("severity") ?? ""),
      details: String(form.get("details") ?? "").trim(),
    };

    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: JSON.stringify({ ...values, _gotcha: String(form.get("_gotcha") ?? "") }),
    });
    const result = await response.json();
    if (!result.ok) {
      return fail(response.status, { values, fields: (result.error.fields ?? {}) as Record<string, string[]> });
    }
    return { sent: true };
  },
} satisfies Actions;
```

`src/routes/report/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let fields = $derived(form?.fields ?? {});

  const SEVERITIES = [
    ["cosmetic", "Looks wrong"],
    ["broken", "Something doesn't work"],
    ["blocking", "I can't continue"],
  ];
</script>

{#if form?.sent}
  <p role="status">Thanks. We'll look at that page today.</p>
{:else}
  <form method="POST" use:enhance>
    <label for="email">Your email</label>
    <input id="email" name="email" type="email" required value={form?.values?.email ?? ""} />
    {#if fields.email}<p>{fields.email[0]}</p>{/if}

    <label for="page">Page address</label>
    <input id="page" name="page" type="url" required value={form?.values?.page ?? ""} />
    {#if fields.page}<p>{fields.page[0]}</p>{/if}

    <label for="severity">How bad is it</label>
    <select id="severity" name="severity">
      {#each SEVERITIES as [value, label]}
        <option {value} selected={(form?.values?.severity ?? "broken") === value}>{label}</option>
      {/each}
    </select>

    <label for="details">What happened</label>
    <textarea id="details" name="details" rows="5" minlength="20" required value={form?.values?.details ?? ""}></textarea>
    {#if fields.details}<p>{fields.details[0]}</p>{/if}

    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button>Send report</button>
  </form>
{/if}
```

**Nuxt**

Eine Server-Route leitet die Meldung mit `runtimeConfig.ikiformSecretKey` weiter, gesetzt aus `NUXT_IKIFORM_SECRET_KEY`. Sie wirft `createError` mit den Feldfehlern in `data`, die `$fetch` auf dem Client zurückgibt.

`server/api/report.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const body = await readBody<Record<string, string>>(event);
  const { ikiformSecretKey } = useRuntimeConfig(event);
  const origin = getRequestHeader(event, "origin");

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      email: body.email ?? "",
      page: body.page ?? "",
      severity: body.severity ?? "",
      details: body.details ?? "",
      _gotcha: body._gotcha ?? "",
    }),
  });
  const result = await response.json();
  if (!result.ok) {
    throw createError({
      status: response.status,
      message: String(result.error.message),
      data: { fields: result.error.fields ?? {} },
    });
  }
  return { sent: true };
});
```

`app/components/ReportForm.vue`:

```vue
<script setup lang="ts">
const sent = ref(false);
const sending = ref(false);
const fields = ref<Record<string, string[]>>({});

async function send(event: Event) {
  const form = event.target as HTMLFormElement;
  sending.value = true;
  fields.value = {};
  try {
    await $fetch("/api/report", { method: "POST", body: Object.fromEntries(new FormData(form)) });
    sent.value = true;
  } catch (failure) {
    const reply = failure as { data?: { data?: { fields?: Record<string, string[]> } } };
    fields.value = reply.data?.data?.fields ?? { details: ["The report didn't go through. Try again."] };
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <p v-if="sent" role="status">Thanks. We'll look at that page today.</p>
  <form v-else method="post" action="/api/report" @submit.prevent="send">
    <label for="email">Your email</label>
    <input id="email" name="email" type="email" required />
    <p v-if="fields.email">{{ fields.email[0] }}</p>
    <label for="page">Page address</label>
    <input id="page" name="page" type="url" required />
    <p v-if="fields.page">{{ fields.page[0] }}</p>
    <label for="severity">How bad is it</label>
    <select id="severity" name="severity">
      <option value="cosmetic">Looks wrong</option>
      <option value="broken" selected>Something doesn't work</option>
      <option value="blocking">I can't continue</option>
    </select>
    <label for="details">What happened</label>
    <textarea id="details" name="details" rows="5" minlength="20" required />
    <p v-if="fields.details">{{ fields.details[0] }}</p>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button :disabled="sending">{{ sending ? "Sending" : "Send report" }}</button>
  </form>
</template>
```

**React**

Eine Single-Page-App sendet aus dem Browser, also gibt es hier keinen Secret Key; **Allowed domains** schützt das Formular. `Accept: application/json` liefert eine JSON-Antwort statt einer Weiterleitung.

`src/ReportForm.tsx`:

```tsx
import { type FormEvent, useState } from "react";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const SEVERITIES = [
  ["cosmetic", "Looks wrong"],
  ["broken", "Something doesn't work"],
  ["blocking", "I can't continue"],
];

export function ReportForm() {
  const [sent, setSent] = useState(false);
  const [sending, setSending] = useState(false);
  const [fields, setFields] = useState<Record<string, string[]>>({});
  const [problem, setProblem] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setSending(true);
    setFields({});
    setProblem("");
    try {
      const response = await fetch(ENDPOINT, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const result = await response.json();
      if (result.ok) setSent(true);
      else if (result.error.fields) setFields(result.error.fields);
      else setProblem(result.error.message);
    } catch {
      setProblem("Couldn't reach the server. Check your connection and try again.");
    } finally {
      setSending(false);
    }
  }

  if (sent) return <p role="status">Thanks. We'll look at that page today.</p>;

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="email">Your email</label>
      <input id="email" name="email" type="email" required aria-invalid={Boolean(fields.email)} />
      {fields.email ? <p>{fields.email[0]}</p> : null}
      <label htmlFor="page">Page address</label>
      <input id="page" name="page" type="url" required aria-invalid={Boolean(fields.page)} />
      {fields.page ? <p>{fields.page[0]}</p> : null}
      <label htmlFor="severity">How bad is it</label>
      <select id="severity" name="severity" defaultValue="broken">
        {SEVERITIES.map(([value, label]) => (
          <option key={value} value={value}>{label}</option>
        ))}
      </select>
      <label htmlFor="details">What happened</label>
      <textarea id="details" name="details" rows={5} minLength={20} required aria-invalid={Boolean(fields.details)} />
      {fields.details ? <p>{fields.details[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {problem ? <p role="alert">{problem}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send report"}</button>
    </form>
  );
}
```

**Vue**

Derselbe Browser-Post in einer Vue-Komponente. Das Formular behält eine echte `action` und `method`, sendet also weiterhin auf die einfache Art, falls das Skript nicht lädt.

`src/components/ReportForm.vue`:

```vue
<script setup lang="ts">
import { ref } from "vue";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

const sent = ref(false);
const sending = ref(false);
const fields = ref<Record<string, string[]>>({});
const problem = ref("");

async function send(event: SubmitEvent) {
  sending.value = true;
  fields.value = {};
  problem.value = "";
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(event.target as HTMLFormElement),
    });
    const result = await response.json();
    if (result.ok) sent.value = true;
    else if (result.error.fields) fields.value = result.error.fields;
    else problem.value = result.error.message;
  } catch {
    problem.value = "Couldn't reach the server. Check your connection and try again.";
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <p v-if="sent" role="status">Thanks. We'll look at that page today.</p>
  <form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="email">Your email</label>
    <input id="email" name="email" type="email" required :aria-invalid="Boolean(fields.email)" />
    <p v-if="fields.email">{{ fields.email[0] }}</p>
    <label for="page">Page address</label>
    <input id="page" name="page" type="url" required :aria-invalid="Boolean(fields.page)" />
    <p v-if="fields.page">{{ fields.page[0] }}</p>
    <label for="severity">How bad is it</label>
    <select id="severity" name="severity">
      <option value="cosmetic">Looks wrong</option>
      <option value="broken" selected>Something doesn't work</option>
      <option value="blocking">I can't continue</option>
    </select>
    <label for="details">What happened</label>
    <textarea id="details" name="details" rows="5" minlength="20" required :aria-invalid="Boolean(fields.details)" />
    <p v-if="fields.details">{{ fields.details[0] }}</p>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="problem" role="alert">{{ problem }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Send report" }}</button>
  </form>
</template>
```

> Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform **Allowed domains** und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das [Rate-Limit](https://ikiform.com/de/docs/rate-limits.md) Relaxed oder Custom, und halte `IKIFORM_SECRET_KEY` in reinen Server-Umgebungsvariablen.

Jede Serverversion leitet den `Origin`-Header des Browsers weiter, wenn es einen gibt, weil ein serverseitiges `fetch` keinen sendet und Ikiform ihn nutzt, um kostenlose `localhost`-Tests in einem Workspace ohne Tarif zu erkennen.

- Jede Version behält eine echte `method` und, wo sie direkt an Ikiform sendet, eine echte `action`. Mit deaktivierten Skripten sendet das Formular trotzdem und bekommt den `303`.
- Der `name` jedes Eingabefelds ist die Feld-`id` in der Definition. Eine Abweichung, etwa `url` statt `page`, bekommt `422` mit `Unknown field "url"`.
- Das versteckte `_gotcha`-Feld ist der Honeypot. Bots, die es ausfüllen, werden stillschweigend als Spam gespeichert.
- Die JavaScript-Versionen zeigen `error.fields` unter jedem Eingabefeld an, jeweils mit der ersten Meldung pro Feld. Die reine HTML-Version überlässt das Ikiforms Fehlerseite.

Die Docs gehen pro Framework tiefer: [Next.js](https://ikiform.com/de/docs/guides/nextjs.md), [Astro](https://ikiform.com/de/docs/guides/astro.md), [SvelteKit](https://ikiform.com/de/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/de/docs/guides/nuxt.md), [React](https://ikiform.com/de/docs/guides/react.md), [Vue](https://ikiform.com/de/docs/guides/vue.md) und [von einem Server](https://ikiform.com/de/docs/guides/server.md).

## Zwei Ebenen der Validierung

Browser-Validierung und Server-Validierung erfüllen unterschiedliche Aufgaben. Die Constraint-Prüfung des Browsers (`required`, `type="email"`, `minlength`, `pattern`) gibt sofortiges Feedback, aber jeder kann sie umgehen: Ein Bot sendet direkt, und ein Skript kann `form.submit()` aufrufen, das die Prüfung nie ausführt. Nur auf dem Server gilt eine Regel wirklich.

| Regel | Im Browser | In Ikiforms Felddefinition |
| --- | --- | --- |
| Pflichtfeld | `required` | `"required": true` |
| E-Mail | `type="email"` | `"type": "email"`, optional mit `allowed_domains` |
| URL | `type="url"` | `"type": "url"`, optional mit `https_only` |
| Länge | `minlength`, `maxlength` | `validation.min_length`, `max_length` |
| Nur gelistete Auswahlwerte | Ein `<select>`, das ein Bot ignoriert | `options`; alles andere wird abgelehnt |
| Keine zusätzlichen Felder | Nicht möglich | Jedes Feld, das nicht in der Definition steht, wird abgelehnt |

Ohne Definition akzeptiert Ikiform beliebige Felder und speichert sie so, wie sie ankommen. Veröffentlichst du eine, validiert Ikiform, speichert Zahlen und Checkboxen als echte Zahlen und Booleans und lehnt den Post mit `422 validation_failed` ab, bevor etwas gespeichert wird. [Validierung](https://ikiform.com/de/guides/html-form-submission/validation.md) zeigt, wie beide Ebenen dasselbe sagen.

## Absenden mit JavaScript

Um den Besucher auf der Seite zu halten, horche auf `submit`, rufe `preventDefault()` auf und sende `new FormData(form)` mit `fetch`. Ein paar Details entscheiden, ob das auf Anhieb funktioniert:

- Setze `Content-Type` nicht selbst, wenn der Body `FormData` ist. Der Browser setzt `multipart/form-data` mit der Boundary; ein von Hand geschriebener Header hat keine Boundary, und der Body lässt sich nicht parsen.
- Sende `Accept: application/json`. Ikiform würde JSON ohnehin anhand von `Sec-Fetch-Mode` wählen, aber der Header macht es explizit, und er gehört zu den Request-Headern, die Browser ohne Preflight cross-site senden können.
- Ein Body aus `FormData` oder `URLSearchParams` ist ein einfacher Cross-Site-Request, also gibt es keinen Preflight. Ein JSON-Body mit `Content-Type: application/json` löst zuerst einen Preflight aus; Ikiform beantwortet ihn, aber das kostet einen Roundtrip.
- `form.submit()` überspringt die Validierung und das `submit`-Event. `form.requestSubmit()` führt beides aus, wie ein Klick.
- Übergib den geklickten Button als zweites Argument, `new FormData(form, event.submitter)`, wenn sein `name` und `value` wichtig sind.

Das vollständige Muster mit Ladezustand, Fokus und Netzwerkfehlern steht unter [ohne Neuladen der Seite](https://ikiform.com/de/guides/html-form-submission/without-page-reload.md). Die Docs zu [JavaScript und fetch](https://ikiform.com/de/docs/frameworks.md) listen jeden Header und jedes Body-Format, das der Endpunkt annimmt.

## Wähle deinen Fall

Jede dieser Seiten nimmt sich einen Teil des Absendens vor und geht weiter als der Überblick oben, mit Code für diesen Fall.

- [Ohne Neuladen der Seite](https://ikiform.com/de/guides/html-form-submission/without-page-reload.md): Sende ein Formular mit fetch und FormData, damit der Besucher auf der Seite bleibt, mit Feldfehlern, Ladezustand, Wiederholungen und einem Fallback, wenn Skripte versagen.
- [Weiterleitung nach dem Absenden](https://ikiform.com/de/guides/html-form-submission/redirect-after-submit.md): Schick Besucher nach dem Absenden auf deine eigene Dankeseite: die 303-Weiterleitung, das Feld _redirect, welche Domains erlaubt sind und was Neuladen bewirkt.
- [Das action-Attribut](https://ikiform.com/de/guides/html-form-submission/action-attribute.md): Was das action-Attribut eines Formulars tut, wie relative und leere Werte aufgelöst werden, wie formaction ein Formular an zwei Ziele schickt und warum statische Hosts mit 405 antworten.
- [Formularvalidierung](https://ikiform.com/de/guides/html-form-submission/validation.md): Validiere ein Formular mit required, type, pattern und min, gestalte Fehler mit :user-invalid und setze dieselben Regeln mit Felddefinitionen auf dem Server durch.

## Prüfen, ob es funktioniert

Geh das einmal pro Formular in einem privaten Fenster durch. Ikiform hat keinen kostenlosen Tarif. Testen ist kostenlos: Ein Workspace ohne Tarif nimmt 50 Test-Einsendungen pro Monat von `localhost` an. Für den Livebetrieb wähle Pro ab 6 $/Monat für 500 Einsendungen oder zahle einmalig: 10.000 Einsendungen für 49 $ oder 50.000 Einsendungen für 149 $, mit Guthaben, das nie verfällt. Siehe [Preise](https://ikiform.com/de/pricing).

1. Füge unter **Allowed domains** des Formulars die Domain deiner Site hinzu, plus `localhost`, während du entwickelst. Lass das Feld leer, um Posts von überall anzunehmen.
2. Deaktiviere JavaScript in den Entwicklertools deines Browsers und sende das Formular ab. Es sollte trotzdem mit einem `303` auf deiner Dankeseite landen.
3. Öffne mit aktiviertem JavaScript den Network-Tab und sende erneut. Der Request an `/f/<formId>` sollte mit `201` und JSON antworten, und die Seite sollte nicht neu laden.
4. Sende mit einer zu kurzen Nachricht ab. Der einfache Post zeigt Ikiforms Fehlerseite mit deinem Feldlabel; die Skriptversion zeigt die Meldung unter dem Feld.
5. Lade auf der Dankeseite neu. Nichts sollte erneut gesendet werden, und es sollte keine zweite Einsendung erscheinen.
6. Öffne **Submissions** im Dashboard. Die neue Einsendung ist da, mit jedem Feld, das du gesendet hast.
7. Sende einen Post mit ausgefülltem Honeypot, `_gotcha=bot`. Er bekommt trotzdem eine Erfolgsantwort, aber die Einsendung wird als Spam gespeichert und löst weder E-Mail noch Webhook aus.

## Fehlerbehebung

| Problem | Was zu tun ist |
| --- | --- |
| `405 Method Not Allowed` nach dem Absenden | Das Formular hat an deinen eigenen statischen Host gesendet, der kein POST annehmen kann. Prüfe `action`: Sie muss die vollständige URL `https://ikiform.com/f/<formId>` sein, kein relativer Pfad und nicht leer. |
| Den Endpunkt in einem Tab zu öffnen zeigt `404 not_found` | Erwartet. Ein Tab sendet GET, und der Endpunkt nimmt nur POST an. Prüfe, ob die `method` des Formulars `post` ist; ein Formular ohne method sendet GET mit den Feldern in der URL. |
| `415 unsupported_media_type` | Das Formular hat `enctype="text/plain"`, oder ein Skript hat einen Typ gesendet, den Ikiform nicht liest. Entferne das `enctype` oder nutze `multipart/form-data`. |
| `422 validation_failed` | Der Post passt nicht zur Felddefinition des Formulars. `error.fields` nennt jedes Feld und den Grund. Gleiche den `name` des Eingabefelds an die Feld-ID an. |
| Der Browser landet auf der Dankeseite von Ikiform statt auf deiner | `_redirect` wird ignoriert, außer seine Domain steht unter **Allowed domains** und es ist eine `https:`-URL. Füge die Domain hinzu oder setze stattdessen **Redirect after submit** in den Einstellungen des Formulars. |
| Ein Feld fehlt in der Einsendung | Es hat keinen `name`, ist `disabled` oder ist eine nicht angehakte Checkbox. Gib ihm einen `name`, nutze `readonly` statt `disabled` oder definiere ein `checkbox`-Feld, damit eine nicht angehakte Box als `false` gespeichert wird. |
| Neuladen fragt, ob das Formular erneut gesendet werden soll | Die Seite hat den POST mit `200` beantwortet. Posts direkt an Ikiform bekommen immer einen `303`; sitzt dein eigener Server dazwischen, leite nach der Verarbeitung des Posts weiter. |
| `403 origin_not_allowed`, oder jeder Post landet im Spam | Die Domain der Seite steht nicht unter **Allowed domains** des Formulars. Füge sie hinzu, plus `localhost` für lokale Tests. Subdomains einer eingetragenen Domain sind erlaubt. |

## Fragen

### Was macht das action-Attribut in einem HTML-Formular?

Es ist die URL, die die Daten des Formulars empfängt. Ein relativer Wert wird relativ zur Adresse der Seite aufgelöst, und ein Formular ohne action sendet an die Seite selbst. Ein Absende-Button kann es mit formaction überschreiben.

### Sollte ein Formular GET oder POST verwenden?

POST für alles, was personenbezogene Daten sendet oder Zustand ändert, etwa Kontakt-, Anmelde- oder Bestellformulare. GET ist für Such- und Filterformulare, bei denen die Felder in eine URL gehören, die man teilen kann.

### Warum sendet das Neuladen der Seite mein Formular erneut?

Die Seite, die du siehst, ist die direkte Antwort auf einen POST, also bietet der Browser an, ihn zu wiederholen. Beantworte einen erfolgreichen POST stattdessen mit einer 303-Weiterleitung auf eine Dankeseite. Ikiform tut das bei jedem Browser-Post.

### Kann ein HTML-Formular JSON senden?

Nicht von sich aus. Ein Formular sendet urlencoded oder multipart Daten. Um JSON zu senden, lies das Formular in einem submit-Handler mit FormData aus und sende JSON.stringify(Object.fromEntries(data)) mit fetch. Ikiform akzeptiert alle drei.

### Reicht das required-Attribut, um ein Formular zu validieren?

Nein. Es hilft Menschen, aber Bots senden direkt und umgehen es. Validiere auch auf dem Server. Mit Ikiform definierst du die Felder, und jeder Post wird dagegen geprüft, bevor er gespeichert wird.

### Kann ich ein Formular ohne Backend absenden?

Du brauchst etwas, das den POST empfängt, aber das muss nicht dein Server sein. Richte die action des Formulars auf ein Formular-Backend wie Ikiform aus, und die Seite kann komplett statisch bleiben.

### Kann das Formular Datei-Uploads annehmen?

Noch nicht. Ein Post mit einer Datei wird mit 422 files_not_supported abgelehnt. Bitte stattdessen um einen Link zur Datei, etwa die URL eines geteilten Laufwerks.

## Weitere Anleitungen

- [Ohne Neuladen der Seite](https://ikiform.com/de/guides/html-form-submission/without-page-reload.md): Sende ein Formular mit fetch und FormData, damit der Besucher auf der Seite bleibt, mit Feldfehlern, Ladezustand, Wiederholungen und einem Fallback, wenn Skripte versagen.
- [Weiterleitung nach dem Absenden](https://ikiform.com/de/guides/html-form-submission/redirect-after-submit.md): Schick Besucher nach dem Absenden auf deine eigene Dankeseite: die 303-Weiterleitung, das Feld _redirect, welche Domains erlaubt sind und was Neuladen bewirkt.
- [Das action-Attribut](https://ikiform.com/de/guides/html-form-submission/action-attribute.md): Was das action-Attribut eines Formulars tut, wie relative und leere Werte aufgelöst werden, wie formaction ein Formular an zwei Ziele schickt und warum statische Hosts mit 405 antworten.
- [Formularvalidierung](https://ikiform.com/de/guides/html-form-submission/validation.md): Validiere ein Formular mit required, type, pattern und min, gestalte Fehler mit :user-invalid und setze dieselben Regeln mit Felddefinitionen auf dem Server durch.
- [Eventanmeldung](https://ikiform.com/de/guides/event-registration.md): Nimm Zusagen auf deiner eigenen Eventseite entgegen: Formular, Begleitpersonen-Limit, ehrliche Teilnehmerzahl, Schließen bei voller Kapazität und Check-in-Liste, ohne Ticketing-App.
- [Webhook-Signaturen prüfen](https://ikiform.com/de/guides/webhook-signature-verification.md): Prüfe, ob ein Formular-Webhook wirklich von Ikiform kommt: das HMAC-SHA256-Schema von Standard Webhooks, ein Testvektor, Raw Bodys, Zeitstempel und Secret-Rotation.
- [Warteliste für ein neues SaaS](https://ikiform.com/de/guides/waitlist.md): Baue eine Pre-Launch-Warteliste auf deiner eigenen Landingpage: das Formular, Spamschutz, Duplikate, Bestätigungs-E-Mails und ein sauberer Export am Launch-Tag.
