Anleitungen15 Min. Lesezeit
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.
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.
- Ein Absende-Button, die Enter-Taste in einem Textfeld oder form.requestSubmit() startet das Absenden und löst ein submit-Event aus.
- 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.
- Der Browser baut den Formulardatensatz aus jedem Steuerelement, das einen name hat und nicht deaktiviert ist.
- Er kodiert diesen Datensatz mit dem enctype des Formulars, standardmäßig urlencoded.
- Er navigiert mit der method des Formulars zur action-URL und sendet den Body mit.
- 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.
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 | 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:
<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. Eineidallein sendet nichts. disabled-Steuerelemente werden übersprungen. Nutzereadonly, 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 ohnevaluesendeton. - Nur der Button, der das Formular abgesendet hat, wird mitgesendet, mit seinem
nameundvalue. <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 einemform-Attribut anschließen, das dieiddes 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:
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.
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--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 |
| 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.
- Bei einer 200-Antwort sendet der Browser das Formular und zeigt den Response-Body als Seite an.
- Diese Seite ist an den POST gebunden, also fragt ein Neuladen nach erneutem Senden, und eine Bestätigung sendet die Daten noch einmal.
- Bei einer 303-Antwort sendet der Browser das Formular und wird angewiesen, eine andere URL zu laden.
- Er lädt diese URL per GET. Ein Neuladen wiederholt nur den GET, also wird nichts doppelt gesendet.
| 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 |
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.comWie 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.
- Ein einfacher Formular-Post navigiert im Tab, also sendet der Browser Sec-Fetch-Mode: navigate.
- Ikiform antwortet im Seitenmodus: mit einer 303-Weiterleitung bei Erfolg oder einer gehosteten Fehlerseite mit dem echten Status des Fehlers.
- Ein fetch-Aufruf sendet Sec-Fetch-Mode: cors, oder dein Code fordert über den Accept-Header JSON an.
- Ikiform antwortet im JSON-Modus: 201 mit der ID der Einsendung oder dem Fehler-Envelope mit Feldfehlern nach Feld-ID.
Wo ein erfolgreicher Post landet
Ein erfolgreicher Browser-Post bekommt einen 303. Ikiform wählt das Ziel in dieser Reihenfolge:
- Die Einstellung Redirect after submit (Weiterleitung nach dem Absenden) des Formulars im Dashboard. Jede
http:- oderhttps:-URL funktioniert, weil nur dein Workspace sie setzen kann. - Ein
_redirect-Feld im Post, oder_next, wenn es kein_redirectgibt, sofern es einehttps:-URL ohne Benutzername, Passwort oder eigenen Port ist und auf einer der Allowed domains des Formulars oder einer Subdomain davon liegt. - Andernfalls die gehostete Dankeseite unter
/f/<formId>/thanks. Kam der Post von einerhttps:-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 behandelt Dankeseiten ausführlich, und die vollständigen Regeln stehen unter HTML-Formulare.
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.
{
"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.
Vorschau
Führt den Validator von Ikiform in deinem Browser aus. Es wird nichts gesendet.
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.
<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>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.
"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,
};
}"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>
);
}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.
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[]> };
},
}),
};---
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>
)}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.
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>
);
}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.
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>
);
}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.
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;<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}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.
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 };
});<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>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.
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>
);
}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.
<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>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
methodund, wo sie direkt an Ikiform sendet, eine echteaction. Mit deaktivierten Skripten sendet das Formular trotzdem und bekommt den303. - Der
namejedes Eingabefelds ist die Feld-idin der Definition. Eine Abweichung, etwaurlstattpage, bekommt422mitUnknown field "url". - Das versteckte
_gotcha-Feld ist der Honeypot. Bots, die es ausfüllen, werden stillschweigend als Spam gespeichert. - Die JavaScript-Versionen zeigen
error.fieldsunter 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, Astro, SvelteKit, Nuxt, React, Vue und von einem Server.
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 |
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 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-Typenicht selbst, wenn der BodyFormDataist. Der Browser setztmultipart/form-datamit 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 vonSec-Fetch-Modewä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
FormDataoderURLSearchParamsist ein einfacher Cross-Site-Request, also gibt es keinen Preflight. Ein JSON-Body mitContent-Type: application/jsonlöst zuerst einen Preflight aus; Ikiform beantwortet ihn, aber das kostet einen Roundtrip. form.submit()überspringt die Validierung und dassubmit-Event.form.requestSubmit()führt beides aus, wie ein Klick.- Übergib den geklickten Button als zweites Argument,
new FormData(form, event.submitter), wenn seinnameundvaluewichtig sind.
Das vollständige Muster mit Ladezustand, Fokus und Netzwerkfehlern steht unter ohne Neuladen der Seite. Die Docs zu JavaScript und fetch 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 SeiteSende 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 AbsendenSchick Besucher nach dem Absenden auf deine eigene Dankeseite: die 303-Weiterleitung, das Feld _redirect, welche Domains erlaubt sind und was Neuladen bewirkt.
- Das action-AttributWas 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.
- FormularvalidierungValidiere 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.
- 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. - Deaktiviere JavaScript in den Entwicklertools deines Browsers und sende das Formular ab. Es sollte trotzdem mit einem
303auf deiner Dankeseite landen. - Öffne mit aktiviertem JavaScript den Network-Tab und sende erneut. Der Request an
/f/<formId>sollte mit201und JSON antworten, und die Seite sollte nicht neu laden. - Sende mit einer zu kurzen Nachricht ab. Der einfache Post zeigt Ikiforms Fehlerseite mit deinem Feldlabel; die Skriptversion zeigt die Meldung unter dem Feld.
- Lade auf der Dankeseite neu. Nichts sollte erneut gesendet werden, und es sollte keine zweite Einsendung erscheinen.
- Öffne Submissions im Dashboard. Die neue Einsendung ist da, mit jedem Feld, das du gesendet hast.
- 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
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.
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.
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.
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.
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.
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.
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.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.