# Ein HTML-Formular ohne Neuladen der Seite absenden

> Sende ein Formular mit fetch und FormData, damit der Besucher auf der Seite bleibt, mit Feldfehlern, Ladezustand, Wiederholungen und einem Fallback, wenn Skripte versagen.

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

Horche auf das `submit`-Event des Formulars, rufe `preventDefault()` auf und sende `new FormData(form)` mit `fetch` und einem `Accept: application/json`-Header. Ikiform antwortet dann mit JSON statt einer Weiterleitung: `201` bei Erfolg oder Feldfehler, die du neben jedem Eingabefeld anzeigst. Behalte eine echte `action` und `method` am Formular, damit es weiterhin auf die einfache Art sendet, falls dein Skript nie läuft.

Das Beispiel hier ist eine Box **Was this page helpful?** (War diese Seite hilfreich?) am Ende einer Docs-Seite. Das ist der klassische Fall, um auf der Seite zu bleiben: Der Leser steckt mitten in einer Aufgabe, und ihn für einen einzigen Klick auf eine Dankeseite zu schicken, wäre absurd. Dasselbe Muster funktioniert für jedes Formular.

## Was sich ändert, wenn du das Absenden übernimmst

Ein einfacher Formular-Post bringt dir viel gratis: Der Browser zeigt eine Ladeanzeige, macht Doppelklicks weitgehend harmlos, kümmert sich um den Zurück-Button und zeigt die Seite, die zurückkommt. Sobald du `preventDefault()` aufrufst, wird all das deine Aufgabe. Der fetch-Aufruf selbst sind drei Zeilen; der Rest dieser Seite ist die Arbeit, die vorher der Browser erledigt hat.

Diagramm: Was ein Skript tut, wenn es ein Formular mit fetch absendet, statt den Browser navigieren zu lassen

1. Der Browser führt zuerst seine Constraint-Prüfung aus. Das submit-Event wird nur ausgelöst, wenn das Formular gültig ist.
2. Dein Listener ruft preventDefault auf, damit der Tab nicht navigiert.
3. Er sendet die Daten des Formulars mit fetch und fordert JSON an.
4. Bei 201 ersetzt er das Formular durch eine Statusmeldung und setzt den Fokus darauf.
5. Bei einem 422 oder einem Netzwerkfehler zeigt er die Meldung neben dem Feld, fokussiert es und aktiviert das Senden wieder.

Die Validierung des Browsers läuft weiterhin vor deinem Code. Alles nach preventDefault liegt bei dir.

| Das hat der Browser getan | Jetzt muss dein Skript |
| --- | --- |
| Zeigte einen Ladezustand im Tab | Einen anzeigen und Screenreadern ansagen |
| Machte Doppelklicks weitgehend harmlos | Ein zweites Absenden ignorieren, solange eines läuft |
| Zeigte die Fehlerseite | `error.fields` jedem Eingabefeld zuordnen |
| Zeigte eine Netzwerk-Fehlerseite | Fehlgeschlagene Requests und Timeouts abfangen |
| Setzte den Fokus auf die neue Seite | Fokus auf das Ergebnis oder das erste fehlerhafte Feld setzen |

## Das Formular

Drei Felder: ein Pflichtfeld Ja oder Nein, ein optionaler Kommentar und die Seitenadresse, die das Skript ausfüllt. Probier die Definition unten aus; Ikiform setzt sie auf dem Server durch, also bekommt ein Kommentar über 2.000 Zeichen auch hier einen `422`.

Formular: Was this page helpful

- **War diese Seite hilfreich?** (`helpful`, radio, Pflichtfeld)
- **Was hat gefehlt?** (`comment`, textarea)
- **Seite** (`page`, url)

Füge das im Tab **Fields** des Formulars ein. Mit definierten Feldern lehnt Ikiform außerdem jedes Feld ab, das es nicht kennt.

`feedback.html`:

```html
<form id="page-feedback" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <fieldset>
    <legend>Was this page helpful?</legend>
    <label><input type="radio" name="helpful" value="yes" required /> Yes</label>
    <label><input type="radio" name="helpful" value="no" /> No</label>
  </fieldset>

  <label for="comment">What was missing?</label>
  <textarea id="comment" name="comment" rows="3" maxlength="2000" aria-describedby="comment-error"></textarea>
  <p id="comment-error" hidden></p>

  <input type="hidden" name="page" />
  <input type="hidden" name="_redirect" value="https://docs.example.com/feedback-sent" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send</button>
  <p id="feedback-status" role="status" tabindex="-1"></p>
</form>

<script type="module" src="/page-feedback.js"></script>
```

Das Formular ist ohne das Skript vollständig. Ist JavaScript aus oder blockiert, sendet es auf dem einfachen Weg, Ikiform antwortet mit `303`, und der Leser landet auf der `_redirect`-Seite. Das Skript verbessert das nur.

## Das Skript

`page-feedback.js`:

```js
const form = document.querySelector("#page-feedback");
const status = form.querySelector("#feedback-status");
const button = form.querySelector("button");
const pageInput = form.elements.namedItem("page");
const idempotencyKey = crypto.randomUUID();
let sending = false;

pageInput.value = location.href;

function showFieldErrors(fields) {
  for (const input of form.querySelectorAll("[aria-invalid]")) input.removeAttribute("aria-invalid");
  for (const hint of form.querySelectorAll("[id$='-error']")) hint.hidden = true;
  let first = null;
  for (const [name, messages] of Object.entries(fields)) {
    const control = form.elements.namedItem(name);
    const hint = form.querySelector(`#${name}-error`);
    if (control instanceof HTMLElement) control.setAttribute("aria-invalid", "true");
    if (hint) {
      hint.textContent = messages[0];
      hint.hidden = false;
    }
    first ??= control instanceof RadioNodeList ? control[0] : control;
  }
  first?.focus();
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  if (sending) return;
  const body = new FormData(form, event.submitter);
  body.set("_idempotency", idempotencyKey);
  sending = true;
  button.setAttribute("aria-disabled", "true");
  status.textContent = "Sending...";

  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body,
      signal: AbortSignal.timeout(15000),
    });
    const result = await response.json();

    if (result.ok) {
      form.replaceChildren(status);
      status.textContent = "Thanks, that helps.";
      status.focus();
      return;
    }
    if (result.error.code === "challenge_required") {
      form.submit();
      return;
    }
    if (result.error.fields) {
      status.textContent = "";
      showFieldErrors(result.error.fields);
    } else if (response.status === 429 && response.headers.has("Retry-After")) {
      const wait = response.headers.get("Retry-After");
      status.textContent = `Too many tries. Wait ${wait} seconds and send again.`;
    } else {
      status.textContent = result.error.message;
    }
  } catch {
    status.textContent = "Couldn't reach the server. Check your connection and send again.";
  } finally {
    sending = false;
    button.removeAttribute("aria-disabled");
  }
});
```

- `new FormData(form, event.submitter)` liest jedes benannte Element, plus `name` und `value` des geklickten Buttons, falls er welche hat. Baue es, bevor du irgendetwas im Formular änderst.
- Der Button bekommt `aria-disabled`, nicht `disabled`. Das Flag `sending` übernimmt das eigentliche Blockieren, und der Button bleibt fokussierbar, sodass Screenreader-Nutzer ihre Position nicht verlieren.
- Kein `Content-Type`-Header. Mit einem `FormData`-Body setzt der Browser `multipart/form-data` mit der richtigen Boundary.
- `Accept: application/json` lässt Ikiform in JSON antworten. Ein `fetch`-Aufruf sendet `Sec-Fetch-Mode: cors`, was ohnehin JSON wählen würde, aber der Header macht die Absicht deutlich.
- Ein Idempotency Key pro Seitenaufruf, gesendet als Feld `_idempotency`. Sendet der Leser ab, verliert das Netzwerk die Antwort und sendet er erneut, gibt Ikiform die erste Antwort wieder, statt eine zweite Einsendung zu speichern. Ein abgelehnter Post beansprucht den Key nie, also geht ein korrigiertes erneutes Senden durch. Der Header `Idempotency-Key` tut dasselbe, aber ein eigener Header lässt den Browser zuerst einen Preflight-Request senden. [JavaScript und fetch](https://ikiform.com/de/docs/frameworks.md#idempotency) enthält die genauen Regeln.
- `AbortSignal.timeout(15000)` macht aus einem hängenden Request den catch-Zweig statt eines endlosen **Sending...**.
- Die Statuszeile hat `role="status"`, also wird ihr Text vorgelesen, ohne den Fokus zu verschieben. Bei Erfolg wandert der Fokus dorthin, weil die Felder drumherum weg sind.

## Wenn das Formular eine Menschlichkeitsprüfung hat

Ist der **Spam check** des Formulars auf **Ikiform** gesetzt, bekommt ein `fetch`-Post ohne Turnstile-Token `403 challenge_required`. Das Skript oben behandelt das, indem es `form.submit()` aufruft. Diese Methode überspringt den Submit-Listener und sendet auf dem einfachen Weg, und bei einem einfachen Post zeigt Ikiform seine eigene kurze Prüfseite und geht dann weiter zu deiner `_redirect`-Seite. Der Leser verlässt die Seite nur, wenn die Prüfung eingeschaltet ist.

Um auch dann auf der Seite zu bleiben, hol dir zuerst ein Token mit `challenge(formId)` aus dem [SDK](https://ikiform.com/de/docs/sdk.md) und übergib es an `submit()`. Die [Spam-Doku](https://ikiform.com/de/docs/spam.md#human-check) zeigt den Ablauf.

## Die Alternative ohne JavaScript

Vor `fetch` bestand der Trick darin, ein verstecktes iframe als Ziel zu setzen, damit die Antwort dort lädt und die Seite bleibt, wo sie ist:

`Alter Trick`:

```html
<iframe name="feedback-sink" hidden></iframe>
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" target="feedback-sink">
  ...
</form>
```

Das funktioniert noch, aber das iframe enthält eine Seite auf ikiform.com, und der Browser lässt dein Skript kein Cross-Origin-Frame lesen. Du kannst Erfolg nicht von einem Validierungsfehler unterscheiden, also bekommt der Leser in keinem Fall Rückmeldung. Nutze es nur, wo ein Skript wirklich nicht laufen kann.

> Ein Server-Framework kann dir Senden ohne Neuladen geben, ganz ohne fetch-Code im Client: Next.js Server Actions, `use:enhance` in SvelteKit und `<Form>` in React Router senden vor der Hydration auf dem einfachen Weg und danach ohne Neuladen. Die [Übersicht](https://ikiform.com/de/guides/html-form-submission.md#implementation) zeigt jede Variante.

## Prüfen, ob es funktioniert

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. Sende **Yes** ohne Kommentar ab. Das Formular wird zu **Thanks, that helps.**, und das Network-Panel zeigt `201`.
3. Füge mehr als 2.000 Zeichen in den Kommentar ein, entferne `maxlength` in den Entwicklertools und sende ab. Die Meldung erscheint unter dem Kommentar, und der Fokus wandert dorthin.
4. Doppelklicke auf **Send**. Das Dashboard zeigt eine Einsendung, nicht zwei.
5. Schalte JavaScript aus und sende ab. Du landest auf der `_redirect`-Seite.
6. Setze die Drosselung auf **Offline** und sende ab. Die Statuszeile sagt, dass der Server nicht erreichbar war, und **Send** funktioniert wieder.

## Stolperfallen

| Problem | Ursache und Lösung |
| --- | --- |
| Ein Feld fehlt im Post | Es war `disabled`, als `FormData` gebaut wurde. Deaktivierte Elemente werden nie gesendet. Baue die Daten zuerst oder sperre Inputs mit `readonly` |
| `400 invalid_body` oder eine leere Einsendung | Der Code hat `Content-Type: multipart/form-data` von Hand gesetzt, ohne Boundary. Lösche den Header |
| Die Seite lädt trotzdem neu | Der Listener hat vor `preventDefault()` einen Fehler geworfen, oder der Klick kam, bevor das Skript geladen war. Ruf zuerst `preventDefault()` auf; ein früher Klick sendet auf dem einfachen Weg, was trotzdem funktioniert |
| `response.json()` wirft einen Fehler | Der Request ging an eine Adresse, die HTML geantwortet hat, meist eine relative `action`. Lies `form.action` in der Konsole; es muss deine Ikiform-URL sein |
| `403 challenge_required` | Die Menschlichkeitsprüfung des Formulars ist an. Weiche wie oben auf `form.submit()` aus oder hol dir ein Token mit `challenge()` |
| `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

### Wie verhindere ich, dass ein Formular beim Absenden die Seite neu lädt?

Füge einen Submit-Listener hinzu und ruf event.preventDefault() als erste Zeile auf, dann sende die Daten selbst mit fetch. Lausche auf das submit-Event des Formulars, nicht auf den click des Buttons, damit auch die Enter-Taste abgedeckt ist.

### Muss ich Content-Type setzen, wenn ich FormData mit fetch sende?

Nein, und das solltest du auch nicht. Der Browser setzt multipart/form-data mit dem Boundary-String. Ein selbst geschriebener Header hat keine Boundary, und der Server kann den Body nicht parsen.

### Validiert der Browser das Formular noch, wenn ich es mit JavaScript absende?

Ja, solange du auf das submit-Event lauschst, das erst feuert, nachdem die Constraint-Prüfung des Browsers bestanden ist. Ein Aufruf von form.submit() im Code überspringt sowohl die Prüfung als auch das Event; form.requestSubmit() führt beide aus.

### Wie verhindere ich doppelte Einsendungen?

Ignoriere Absendungen, solange ein Request läuft, und sende einen Idempotency Key pro Seitenaufruf, als Feld _idempotency oder als Header Idempotency-Key. Erreicht ein erneuter Versuch Ikiform trotzdem, gibt es die erste Antwort wieder, statt die Einsendung erneut zu speichern.

## Weitere Anleitungen

- [Absenden von HTML-Formularen](https://ikiform.com/de/guides/html-form-submission.md): 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.
- [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.
- [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.
