Anleitungen14 Min. Lesezeit
Kontaktformular auf einer statischen Site ohne Backend einbauen
Bau ein funktionierendes Kontaktformular in eine statische Site ohne Server ein: wohin der POST geht, Spam, Antworten per E-Mail und Code für jeden Host und jedes Framework.
Ein statischer Host liefert Dateien aus und lehnt einen Formular-POST ab, meist mit 405 oder 404, also muss das Formular woandershin senden. Richte die action des Formulars auf ein Formular-Backend wie Ikiform aus, das jede Nachricht speichert, Spam filtert und dir eine E-Mail mit dem Absender als Reply-To-Adresse schickt. Deine Site bleibt statisch: kein Servercode, keine Secret Keys in der Seite und keine Änderung an deinem Deployment.
Eine statische Site ist ein Ordner mit HTML, CSS und JavaScript, den ein Host als Dateien ausliefert. Deshalb ist sie schnell, günstig und schwer kaputtzumachen. Und deshalb ist das Kontaktformular das Erste, was nicht mehr funktioniert: Eine Formulareinsendung ist ein Request, den irgendetwas annehmen muss, und auf einem statischen Host hört niemand zu.
Warum das Formular auf einem statischen Host scheitert
Ein <form method="post"> ohne action sendet an die Seite, auf der es steht. Auf einem statischen Host ist das eine Datei, und Dateien beantworten nur GET und HEAD. Wir haben am 25.10.2026 bei jedem großen Host einen POST an eine live geschaltete statische Seite gesendet. Keiner hat etwas gespeichert:
| Host | Was ein POST an eine statische Seite bekommt |
|---|---|
| GitHub Pages | 405 Not Allowed, als kleine HTML-Seite |
| Vercel | 405 mit leerem Body |
| Netlify | 404, mit der 404-Seite der Site, falls vorhanden, außer Netlify Forms hat das Formular beim Deployment erkannt |
| Cloudflare Pages | 405 mit leerem Body |
| Website-Endpunkt von Amazon S3 | 405 Method Not Allowed, Code MethodNotAllowed |
Die Lösung ist überall dieselbe: Schick den Post an eine URL, die ihn annimmt. Diese URL kann Code sein, den du selbst schreibst und hostest, eine Funktion deines Hosts oder ein Formular-Backend. Der Rest dieser Anleitung nutzt ein Formular-Backend, weil die Site so statisch bleibt.
- Ohne action oder mit einer action, die auf einen Pfad deiner eigenen Site zeigt, sendet der Browser an den statischen Host.
- Der Host hat keinen Code, den er für einen POST ausführen könnte, also antwortet er mit 405 oder 404, und die Nachricht ist weg.
- Zeigt die action auf deinen Ikiform-Endpunkt, geht der Post an einen Server, der ihn annimmt.
- Ikiform prüft die Nachricht auf Spam, speichert sie und schickt dir eine E-Mail.
- Ikiform antwortet mit einer 303-Weiterleitung, und der Browser lädt die Dankeseite auf deiner eigenen Site.
Was mit jeder Nachricht passiert
Ikiform ist der Endpunkt, an den das Formular sendet. Es hostet weder deine Seite noch rendert es dein Formular; beides behältst du. Für jeden Post führt es die Spamprüfungen aus, speichert die Nachricht und verteilt sie an die Orte, an denen du sie liest.
- Ein Besucher füllt das Kontaktformular aus, und es wird an deinen Ikiform-Endpunkt gesendet.
- Ikiform prüft Honeypot, Rate-Limit und Spam-Score und speichert dann die Nachricht.
- Jeder Benachrichtigungsempfänger bekommt eine E-Mail, mit der Adresse des Absenders als Reply-To.
- Die Nachricht erscheint im Dashboard, wo du sie durchsuchen, Spam wiederherstellen und als CSV exportieren kannst.
- Wenn du einen Webhook hinzufügst, sendet Ikiform eine signierte Kopie an Slack, Discord, ein Automatisierungstool oder deinen eigenen Code.
Fünf Wege, das Formular einer statischen Site zu empfangen
| Ansatz | Stark bei | Nachteile |
|---|---|---|
Ein mailto:-Link oder action="mailto:..." | Null Einrichtung. Reicht für eine persönliche Seite mit wenig Traffic. | Öffnet die Mail-App des Besuchers, falls eine eingerichtet ist. Nichts wird gespeichert oder gefiltert, und deine Adresse steht für Scraper im HTML. |
| Die Formularfunktion deines Hosts, etwa Netlify Forms | Ein Attribut, kein weiteres Konto und meist im Hosting-Tarif enthalten. | Funktioniert nur bei diesem Host. Ein Hostwechsel bedeutet, das Formular neu zu bauen, und per JavaScript gerenderte Formulare brauchen Umwege. |
| Eine Serverless-Funktion, die du selbst schreibst | Volle Kontrolle über Validierung, Speicherung und die E-Mail, die du verschickst. | Du betreibst jetzt ein Backend: einen API-Schlüssel für E-Mail, Spamfilter, Rate-Limits, Wiederholungsversuche und Logs. Das Funktionsformat ist bei jedem Host anders. |
| Ein gehostetes Formular-Embed, etwa Google Forms, Tally oder Typeform | Kein Code. Gut für lange oder mehrstufige Formulare. | Ein iframe oder ein Link zu einer anderen Site, mit deren Look und deren Skripten auf deiner Seite. |
| Ein Formular-Backend, etwa Ikiform, Formspree, Basin oder Web3Forms | Dein eigenes HTML auf jedem Host. Speicherung, Spamfilter und E-Mail ohne Server. | Ein weiteres Konto und eine weitere Rechnung, und die Formulardaten liegen bei diesem Dienst. |
Wenn du auf Netlify bist und nie wechseln willst, ist Netlify Forms vom Aufwand her kaum zu schlagen. Wenn du das Backend selbst schreiben willst, ist eine Funktion ein gutes Wochenendprojekt. Wenn das Formular weiter funktionieren soll, wenn du Host oder Framework wechselst, nimm ein Formular-Backend. Die Vergleiche stellen die Backends nebeneinander.
Das Formular
Jedes Beispiel unten sendet aus dem Browser, weil ein statisches Deployment keinen Server hat, von dem aus es senden könnte. Wähle deinen Stack; die Wahl gilt für jedes Codebeispiel in diesen Anleitungen. Static-Site-Generatoren haben eigene Einrichtungsseiten: Jekyll, Hugo, Eleventy und Gatsby.
Das ist die ganze Integration für eine handgeschriebene Site. Das Formular sendet auf klassischem Weg, funktioniert also auch mit ausgeschaltetem JavaScript, und der Browser landet nach erfolgreichem Senden auf deiner thanks.html.
<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" maxlength="120" required />
<p class="field-error" data-error-for="name" hidden></p>
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p class="field-error" data-error-for="email" hidden></p>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" minlength="10" maxlength="5000" required></textarea>
<p class="field-error" data-error-for="message" hidden></p>
<input type="hidden" name="_redirect" value="https://example.com/thanks.html" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button type="submit">Send message</button>
<p id="contact-status" role="status"></p>
</form>
<p id="contact-sent" role="status" hidden>Thanks. Your message is on its way, and I'll reply by email.</p>
<script src="/contact.js" defer></script>Das Skript ist optional. Es hält den Besucher auf der Seite, zeigt jeden Feldfehler unter dem zugehörigen Eingabefeld und lässt die getippte Nachricht stehen, wenn etwas schiefgeht.
const form = document.querySelector("#contact");
const status = document.querySelector("#contact-status");
const done = document.querySelector("#contact-sent");
const button = form.querySelector("button");
function showErrors(fields = {}) {
for (const slot of form.querySelectorAll("[data-error-for]")) {
const name = slot.dataset.errorFor;
const message = fields[name]?.[0] ?? "";
slot.textContent = message;
slot.hidden = !message;
form.elements[name]?.setAttribute("aria-invalid", message ? "true" : "false");
}
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true;
status.textContent = "Sending";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
form.hidden = true;
done.hidden = false;
return;
}
showErrors(result.error.fields);
status.textContent = result.error.message;
} catch {
status.textContent = "Couldn't send. Check your connection and try again.";
}
button.disabled = false;
});Mit output: "export" schreibt next build einfache Dateien nach out/. Server Actions und Route Handler, die den Request lesen, werden in einem statischen Export nicht unterstützt, daher sendet das Formular stattdessen aus dem Browser.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
};
export default nextConfig;"use client";
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
type Field = "name" | "email" | "message";
type Errors = Partial<Record<Field, string>>;
export function ContactForm() {
const [sentTo, setSentTo] = useState("");
const [sending, setSending] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [notice, setNotice] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setSending(true);
setNotice("");
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setSentTo(String(body.get("email")));
return;
}
const fields = result.error.fields ?? {};
setErrors({ name: fields.name?.[0], email: fields.email?.[0], message: fields.message?.[0] });
setNotice(result.error.message);
} catch {
setNotice("Couldn't send. Check your connection and try again.");
} finally {
setSending(false);
}
}
if (sentTo) return <p role="status">Thanks. I'll reply to {sentTo}.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" maxLength={120} required aria-invalid={!!errors.name} />
{errors.name ? <p>{errors.name}</p> : null}
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required aria-invalid={!!errors.email} />
{errors.email ? <p>{errors.email}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} minLength={10} required aria-invalid={!!errors.message} />
{errors.message ? <p>{errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{notice ? <p role="alert">{notice}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</form>
);
}Rendere <ContactForm /> aus app/contact/page.tsx. Das Formular behält seine action, sendet also auch, bevor das JavaScript-Bundle geladen ist.
Astro rendert standardmäßig jede Seite vorab, und genau das will ein statischer Host. Greif hier nicht zu Astro Actions: Ein Formular an eine Action zu senden, braucht On-Demand-Rendering und einen Adapter. Sende an Ikiform und lass das gebündelte <script> die Besucher auf der Seite halten.
---
import Layout from "../layouts/Layout.astro";
const endpoint = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
---
<Layout title="Contact">
<form id="contact" action={endpoint} method="post">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" minlength="10" required></textarea>
<input type="hidden" name="_redirect" value={new URL("/thanks/", Astro.site).href} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send message</button>
<output id="contact-status" role="status"></output>
</form>
</Layout>
<script>
const form = document.querySelector<HTMLFormElement>("#contact")!;
const status = document.querySelector<HTMLOutputElement>("#contact-status")!;
form.addEventListener("submit", async (event) => {
event.preventDefault();
status.value = "Sending";
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) {
form.replaceWith(Object.assign(document.createElement("p"), { textContent: "Thanks. Your message is on its way, and I'll reply by email." }));
return;
}
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const first = Object.entries(fields)[0];
status.value = first ? `${first[0]}: ${first[1][0]}` : (result?.error?.message ?? "Couldn't send. Check your connection and try again.");
});
</script>Astro.site kommt aus site in astro.config.mjs. Setze es auf deine https:-Adresse, damit der _redirect-Wert eine vollständige URL ist, der Ikiform folgen kann.
Der SPA-Modus baut eine clientseitig gerenderte Hülle, die jeder statische Host ausliefern kann. Serverfunktionen brauchen trotzdem einen Server, ein statisches Deployment kann sie also nicht ausführen; die Route sendet aus dem Browser und wechselt bei Erfolg zu /thanks.
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
tanstackStart({
spa: {
enabled: true,
},
}),
viteReact(),
],
});import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export const Route = createFileRoute("/contact")({ component: Contact });
function Contact() {
const navigate = useNavigate();
const [problems, setProblems] = useState<string[]>([]);
const [sending, setSending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSending(true);
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) {
await navigate({ to: "/thanks" });
return;
}
const fields: Record<string, string[]> = result.error.fields ?? {};
setProblems(Object.keys(fields).length ? Object.values(fields).map((list) => list[0]) : [result.error.message]);
} catch {
setProblems(["Couldn't send. Check your connection and try again."]);
}
setSending(false);
}
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
{problems.length ? (
<ul role="alert">
{problems.map((problem) => (
<li key={problem}>{problem}</li>
))}
</ul>
) : null}
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} minLength={10} required />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</form>
);
}Mit ssr: false gibt es keinen Server-Build, also kann eine Route keine Server-action haben. Stattdessen läuft eine clientAction im Browser: Sie leitet den Post von <Form> an Ikiform weiter und leitet bei Erfolg um.
import type { Config } from "@react-router/dev/config";
export default {
ssr: false,
} satisfies Config;import { Form, redirect, useNavigation } from "react-router";
import type { Route } from "./+types/contact";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export async function clientAction({ request }: Route.ClientActionArgs) {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: await request.formData(),
});
const result = await response.json();
if (result.ok) return redirect("/thanks");
return {
fields: (result.error.fields ?? {}) as Record<string, string[]>,
message: String(result.error.message),
};
}
export default function Contact({ actionData }: Route.ComponentProps) {
const sending = useNavigation().state === "submitting";
const error = (name: string) => actionData?.fields[name]?.[0];
return (
<Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{error("name") ? <p>{error("name")}</p> : null}
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{error("email") ? <p>{error("email")}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} minLength={10} required />
{error("message") ? <p>{error("message")}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData?.message ? <p role="alert">{actionData.message}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</Form>
);
}Mit adapter-static und prerender = true ist jede Seite eine Datei. SvelteKit-Form-Actions fallen weg: Seiten mit Actions lassen sich nicht vorab rendern, weil ein Server ihren POST verarbeiten muss. Die Komponente sendet stattdessen mit fetch.
export const prerender = true;<script lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
let phase = $state<"idle" | "sending" | "sent">("idle");
let errors = $state<Record<string, string[]>>({});
let notice = $state("");
async function send(event: SubmitEvent) {
event.preventDefault();
phase = "sending";
notice = "";
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.currentTarget as HTMLFormElement),
});
const result = await response.json();
if (result.ok) {
phase = "sent";
return;
}
errors = result.error.fields ?? {};
notice = result.error.message;
} catch {
notice = "Couldn't send. Check your connection and try again.";
}
phase = "idle";
}
</script>
{#if phase === "sent"}
<p role="status">Thanks. Your message is on its way, and I'll reply by email.</p>
{:else}
<form action={ENDPOINT} method="post" onsubmit={send}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required aria-invalid={!!errors.name} />
{#if errors.name}<p>{errors.name[0]}</p>{/if}
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required aria-invalid={!!errors.email} />
{#if errors.email}<p>{errors.email[0]}</p>{/if}
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" minlength="10" required aria-invalid={!!errors.message}></textarea>
{#if errors.message}<p>{errors.message[0]}</p>{/if}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if notice}<p role="alert">{notice}</p>{/if}
<button disabled={phase === "sending"}>{phase === "sending" ? "Sending" : "Send message"}</button>
</form>
{/if}nuxt generate schreibt vorab gerenderte Seiten nach .output/public. Routen in server/api gibt es auf einem statischen Host nicht, daher ruft die Komponente Ikiform direkt mit $fetch auf und wechselt bei Erfolg zu /thanks.
<script setup lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const form = reactive({ name: "", email: "", message: "", _gotcha: "" });
const errors = ref<Record<string, string[]>>({});
const notice = ref("");
const sending = ref(false);
async function send() {
sending.value = true;
notice.value = "";
try {
await $fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body: form });
await navigateTo("/thanks");
} catch (failure) {
const data = (failure as { data?: { error?: { message?: string; fields?: Record<string, string[]> } } }).data;
errors.value = data?.error?.fields ?? {};
notice.value = data?.error?.message ?? "Couldn't send. Check your connection and try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<form :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="form.name" name="name" autocomplete="name" required />
<p v-if="errors.name">{{ errors.name[0] }}</p>
<label for="email">Email</label>
<input id="email" v-model="form.email" name="email" type="email" autocomplete="email" required />
<p v-if="errors.email">{{ errors.email[0] }}</p>
<label for="message">Message</label>
<textarea id="message" v-model="form.message" name="message" rows="6" minlength="10" required />
<p v-if="errors.message">{{ errors.message[0] }}</p>
<input v-model="form._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="notice" role="alert">{{ notice }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>Eine Vite-Single-Page-App auf einem statischen Host. Der Endpunkt steht in einer VITE_-Variable, damit Preview- und Produktions-Builds auf verschiedene Formulare zeigen können; im Bundle landet er so oder so, was in Ordnung ist, weil er kein Geheimnis ist.
VITE_IKIFORM_ENDPOINT=https://ikiform.com/f/frm_xxxxxxxxxxxxxxxximport { type FormEvent, useState } from "react";
const ENDPOINT = import.meta.env.VITE_IKIFORM_ENDPOINT;
type Status = { kind: "idle" } | { kind: "sending" } | { kind: "sent" } | { kind: "failed"; lines: string[] };
export function ContactForm() {
const [status, setStatus] = useState<Status>({ kind: "idle" });
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setStatus({ kind: "sending" });
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) return setStatus({ kind: "sent" });
const fields: Record<string, string[]> = result.error.fields ?? {};
const lines = Object.entries(fields).map(([name, list]) => `${name}: ${list[0]}`);
setStatus({ kind: "failed", lines: lines.length ? lines : [result.error.message] });
} catch {
setStatus({ kind: "failed", lines: ["Couldn't send. Check your connection and try again."] });
}
}
if (status.kind === "sent") return <p role="status">Thanks. Your message is on its way, and I'll reply by email.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} minLength={10} required />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{status.kind === "failed" ? (
<div role="alert">
{status.lines.map((line) => (
<p key={line}>{line}</p>
))}
</div>
) : null}
<button disabled={status.kind === "sending"}>{status.kind === "sending" ? "Sending" : "Send message"}</button>
</form>
);
}Derselbe Browser-Post in einer Vue-Single-File-Component, die den Endpunkt aus derselben VITE_-Variable liest. Nur der erste Fehler wird angezeigt, neben dem Button.
<script setup lang="ts">
import { computed, ref } from "vue";
const ENDPOINT = import.meta.env.VITE_IKIFORM_ENDPOINT;
const sending = ref(false);
const sent = ref(false);
const fields = ref<Record<string, string[]>>({});
const notice = ref("");
const firstError = computed(() => Object.values(fields.value)[0]?.[0] ?? notice.value);
async function send(event: SubmitEvent) {
sending.value = true;
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.target as HTMLFormElement),
});
const result = await response.json();
sent.value = result.ok;
fields.value = result.ok ? {} : (result.error.fields ?? {});
notice.value = result.ok ? "" : result.error.message;
} catch {
notice.value = "Couldn't send. Check your connection and try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sent" role="status">Thanks. Your message is on its way, and I'll reply by email.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required :aria-invalid="!!fields.name" />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required :aria-invalid="!!fields.email" />
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" minlength="10" required :aria-invalid="!!fields.message" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="firstError" role="alert">{{ firstError }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>- Ersetze
frm_xxxxxxxxxxxxxxxxdurch die ID deines Formulars aus dem Dashboard. Die ID ist absichtlich öffentlich: Sie steht in deinem HTML, sobald du deployst. Leg niemals einen geheimen API-Schlüssel in eine statische Site. - Behalte für das E-Mail-Feld den Namen
email. Ikiform setzt es als Reply-To-Adresse der Benachrichtigungs-E-Mails und prüft es gegen Domains von Wegwerfadressen. - Das versteckte Eingabefeld
_gotchaist der Honeypot. Menschen sehen es nie; Bots, die jedes Eingabefeld ausfüllen, markieren sich selbst als Spam. - Die JavaScript-Versionen senden
Accept: application/json, daher antwortet Ikiform bei Erfolg mit201oder mit422und einemerror.fields-Objekt, das jedes Feld und das Problem nennt.
Wenn dein Framework in Produktion doch auf einem Server läuft, kannst du stattdessen von dort senden und den Endpunkt aus der Seite heraushalten. Die Anleitungen zu Next.js, SvelteKit und Nuxt zeigen die Server-Versionen, und Von einem Server behandelt Secret Keys.
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Felder für ein Kontaktformular
Name, E-Mail und Nachricht decken fast jedes Kontaktformular ab. Eine Themenauswahl lohnt sich, wenn verschiedene Leute verschiedene Arten von Nachrichten beantworten. Probier das Formular unten aus: Es führt den Validator von Ikiform in deinem Browser aus und zeigt, was gespeichert würde, oder den 422, den du zurückbekämst.
Vorschau
Führt den Validator von Ikiform in deinem Browser aus. Es wird nichts gesendet.
| Feld | Warum | Beachte |
|---|---|---|
name | Wie du die Person in der Antwort ansprichst | Ein Link in einem Namensfeld ist ein starkes Spam-Signal, also behalte die ID name |
email | Wohin die Antwort geht | Pflicht. type="email" öffnet auf dem Handy die passende Tastatur |
topic | Vertrieb und Support an die richtige Person leiten | Ein Select akzeptiert nur die aufgeführten Werte, darauf lässt sich also sicher filtern |
message | Der Sinn des Formulars | Eine Mindestlänge von 10 verhindert Ein-Wort-Posts |
Lass Telefonnummern, Firmengröße und alles andere weg, was du in deiner ersten Antwort nicht brauchst. Jedes zusätzliche Feld kostet Nachrichten. Ohne Felddefinition speichert Ikiform alle benannten Eingabefelder, die das Formular sendet; mit einer lehnt es Posts ab, die nicht passen.
422 files_not_supported.Aus deinem Postfach antworten
Benachrichtigungs-E-Mails gehen an bis zu 10 bestätigte Empfänger pro Formular. Hat das Formular ein email-Feld, ist der Absender die Reply-To-Adresse der E-Mail, sodass Reply in deiner Mail-App direkt an ihn zurückschreibt. Du kopierst nie eine Adresse aus einem Dashboard.
Benachrichtigungen sind auf 50 E-Mails pro Formular und Stunde begrenzt, danach kommt eine stündliche Zusammenfassung, sodass eine Spamwelle dein Postfach nicht zuschütten kann. Als Spam markierte Nachrichten lösen gar keine E-Mail aus. _subject ändert nicht den Betreff der Benachrichtigungs-E-Mails. Es wird als meta.subject an Webhooks und die API weitergegeben.
Willst du Nachrichten statt oder zusätzlich zur E-Mail in einem Team-Chat? Füge einen Webhook hinzu und folge der Anleitung für Slack, Discord oder Telegram.
Was der Besucher nach dem Senden sieht
Ein einfacher HTML-Post endet mit einer Weiterleitung. Ikiform beantwortet einen erfolgreichen Browser-Post mit 303 See Other, und der Browser lädt das Ziel mit einem GET, den dein statischer Host gern ausliefert.
- Der Browser sendet die Formularfelder an deinen Ikiform-Endpunkt.
- Ikiform speichert die Nachricht und antwortet mit 303 See Other und einem Location-Header.
- Der Browser folgt der Weiterleitung mit einem GET auf deine Dankeseite, die der statische Host wie jede andere Datei ausliefert.
Ikiform wählt das Ziel in dieser Reihenfolge:
- Redirect after submit in den Einstellungen des Formulars. Jede
http:- oderhttps:-URL funktioniert, weil nur dein Team sie setzen kann. Für eine einzelne Site ist das die einfachste Wahl. - Ein
_redirect-Feld im Formular, wenn es einehttps:-URL auf einer der Allowed domains des Formulars ist. Nutze es, wenn ein Formular mehrere Sites oder Sprachen bedient. - Andernfalls die gehostete Dankeseite von Ikiform, die zurück auf deine Site verlinkt, wenn der Post von einer erlaubten
https:-Seite kam.
Mach die Dankeseite zu einer echten Datei und halte sie aus den Suchergebnissen heraus. Sag, wann du antwortest; das erspart dir eine zweite E-Mail von derselben Person.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Message sent</title>
</head>
<body>
<h1>Thanks, your message is on its way</h1>
<p>I read every message and reply within two working days, from the address on this site.</p>
<p><a href="/">Back to the home page</a></p>
</body>
</html>Die JavaScript-Versionen oben überspringen die Weiterleitung. Ein Post mit Accept: application/json bekommt JSON zurück, und die Seite zeigt die Erfolgszeile selbst an.
Spam bei einem statischen Formular
Dein Endpunkt steht im Quelltext deiner Seite, und Bots lesen Quelltext. Rechne innerhalb weniger Tage nach dem Livegang mit Spam. Ikiform schichtet seine Abwehr so, dass du ohne jede Einrichtung starten kannst:
| Abwehr | Was sie stoppt | Einrichtung |
|---|---|---|
| Honeypot | Bots, die jedes Eingabefeld ausfüllen | Das versteckte Eingabefeld _gotcha |
| Spam-Bewertung | Links im Namen (+3), viele Links (+2), bekannte Spam-Phrasen (+2) und Wegwerfadressen. Ab 5 wird eine Nachricht als Spam gespeichert | Keine |
| Rate-Limits | Skripte, die in einer Schleife senden | Standardmäßig aktiv: Standard, 5 pro Minute und Besucher |
| Erlaubte Domains | Kopien deines Formulars, die von anderen Sites senden | Füge deine Domains in den Einstellungen des Formulars hinzu |
| Menschlichkeitsprüfung | Bots, die einen echten Browser steuern | Setze Spam check auf Ikiform für Cloudflare Turnstile ohne Schlüssel |
Spam bekommt dieselbe Erfolgsantwort wie eine echte Nachricht, ein Bot lernt also nichts, und es wird weder E-Mail noch Webhook ausgelöst. Ein Kontaktformular braucht selten mehr als einen Post pro Person in ein paar Minuten, daher passt Strict mit 2 alle 10 Minuten pro Besucher, sobald du live bist. Die Spam-Dokumentation behandelt jede Schicht, und die Menschlichkeitsprüfung braucht in den JavaScript-Versionen einen zusätzlichen Aufruf: challenge() aus dem SDK.
Erlaubte Domains und Preview-Deployments
Allowed domains passt auf eine Domain und ihre Subdomains. Statische Hosts geben dir mehr Domains, als du denkst: die Produktionsdomain, die eigene Subdomain des Hosts und eine Preview-Adresse pro Branch oder Pull Request. Manche Previews sind Subdomains der Host-Adresse deiner Site und passen dazu; andere nicht und brauchen einen eigenen Eintrag.
| Host | Hinzufügen | Previews |
|---|---|---|
| GitHub Pages | username.github.io und deine eigene Domain | Standardmäßig keine Previews |
| Vercel | your-project.vercel.app und deine Domain | Branch-URLs passen nicht; füge beim Testen den genauen Host hinzu |
| Netlify | your-site.netlify.app und deine Domain | deploy-preview-12--your-site.netlify.app passt nicht |
| Cloudflare Pages | your-project.pages.dev und deine Domain | abc123.your-project.pages.dev passt bereits |
Füge niemals einen geteilten Host allein hinzu, etwa github.io, vercel.app oder netlify.app. Jede Site auf diesem Host würde passen, auch die von anderen Leuten.
Wenn deine Site eine Content-Security-Policy sendet
Viele statische Templates bringen eine strenge Content-Security-Policy mit. Zwei Direktiven entscheiden, ob das Formular funktioniert: form-action für den einfachen HTML-Post und connect-src für die fetch-Versionen. Fehlt eine davon, blockiert der Browser den Request, bevor er die Seite verlässt, und zeigt dazu nur eine Meldung in der Konsole.
Content-Security-Policy: default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.comBehalte 'self' in form-action: Manche Browser prüfen auch die Weiterleitung zurück auf deine Dankeseite dagegen. Wo der Header liegt, hängt vom Host ab: _headers bei Netlify und Cloudflare Pages, vercel.json bei Vercel, ein <meta>-Tag bei GitHub Pages und eine CloudFront-Response-Headers-Policy vor S3. Die Host-Seiten unten zeigen jeden Fall.
Vor dem Deployment testen
Öffne die Seite über einen lokalen Server, nicht per Doppelklick auf die Datei. Eine als file:// geöffnete Seite sendet Origin: null, was zu keiner erlaubten Domain passt und nicht als lokaler Test zählt. Jeder dieser Befehle liefert den aktuellen Ordner aus:
npx serve .
python3 -m http.server 8000Füge beim Testen localhost zu Allowed domains hinzu. Posts von localhost zählen als Tests: Ein Workspace ohne Tarif bekommt 50 pro Monat, du kannst also das ganze Formular bauen, bevor du zahlst.
Wähle deinen Fall
Das Formular oben funktioniert auf jedem statischen Host. Jeder Host scheitert auf seine eigene Art und hat eigene Preview-Domains und Header-Dateien. Diese Seiten behandeln sie.
- GitHub PagesGitHub Pages kann keinen Servercode ausführen, also bekommt ein Formular-POST 405 Not Allowed; sende an ein Formular-Backend und setze Weiterleitungen für Projekt-Sites richtig.
- VercelEine statische Seite auf Vercel beantwortet einen Formular-POST mit 405 Method Not Allowed; hier steht, warum, und wie du das Formular stattdessen an ein Backend sendest.
- NetlifyBetreib ein Kontaktformular auf einer Netlify-Site ohne Netlify Forms: keine Erkennung beim Build, JSON-Posts aus React und ein eigenes Formular für Deploy Previews.
- Cloudflare PagesBau ein Kontaktformular in Cloudflare Pages ein, ohne eine Function zu schreiben: an ein Formular-Backend senden, Header in _headers setzen und eigene Turnstile-Schlüssel nutzen.
- Amazon S3S3-Website-Endpunkte akzeptieren nur GET und HEAD über einfaches HTTP, also sende das Kontaktformular an ein Formular-Backend und setze Weiterleitungen mit CloudFront richtig.
- Portfolio-WebsiteEin Portfolio-Kontaktformular mit den Fragen, die du im ersten Gespräch stellen würdest: Projekt, Budget, Zeitplan und ein Link, ohne Server und mit Antworten aus deinem Postfach.
Prüfen, ob es funktioniert
Geh das einmal auf deiner Live-Domain und einmal auf einer Preview durch, falls dein Host welche erstellt. Ikiform hat keinen kostenlosen Tarif. Testen ist kostenlos: Ein Workspace ohne Tarif nimmt 50 Testeinsendungen 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. - Sende von deinem lokalen Server und prüfe, ob die Nachricht unter Submissions erscheint.
- Deploye und sende dann von der Live-Site mit ausgeschaltetem JavaScript. Du solltest auf deiner eigenen Dankeseite landen.
- Schalte JavaScript wieder ein und sende mit leerer Nachricht. Der Fehler sollte auf der Seite erscheinen, nicht als gehostete Fehlerseite.
- Prüfe das Postfach jedes Benachrichtigungsempfängers. Jeder bekommt eine E-Mail mit den Feldern, und Reply geht an den Absender, wenn das Formular ein
email-Feld hat. - 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. - Nichts angekommen? Stell den Statusfilter im Dashboard auf Spam. Spam bekommt dieselbe Erfolgsantwort wie eine echte Einsendung.
Fehlerbehebung
| Problem | Was zu tun ist |
|---|---|
405 Method Not Allowed oder 404 direkt nach dem Klick auf Senden | Das Formular hat an deine eigene Site gesendet. Prüfe das action-Attribut im deployten HTML, nicht in deinem Quellcode; ein Build-Schritt oder eine <Form>-Komponente eines Frameworks kann es ersetzt haben. |
Nichts passiert, und die Konsole erwähnt Content-Security-Policy | Füge https://ikiform.com zu form-action für einfache Posts und zu connect-src für fetch hinzu. |
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. |
Lokale Tests werden mit 402 plan_required abgelehnt | Die Seite wurde als file://-URL geöffnet, daher hatte der Post keinen brauchbaren Origin und zählte als live. Liefere den Ordner über einen lokalen Server aus und teste von localhost. |
| 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. |
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. |
| Die Einsendung ist im Dashboard, aber keine E-Mail und kein Webhook kam an | Sie wurde als Spam gespeichert. Spam löst nichts aus. Öffne sie unter dem Filter Spam, um die Gründe zu sehen, und markiere sie als kein Spam, um ihre E-Mail und Webhooks zu senden. |
429 rate_limited | Ein Besucher hat mehr gesendet, als das Rate-Limit des Formulars erlaubt. Standard erlaubt 5 pro Minute und Besucher. Warte Retry-After ab oder wähle Relaxed oder Custom für Formulare, die von einer gemeinsamen IP aus senden. |
Fragen
Ja. Die Seite bleibt statisch; nur die action des Formulars zeigt auf einen Dienst, der Posts annimmt. Ein Formular-Backend speichert jede Nachricht, filtert Spam und schickt dir eine E-Mail, du betreibst also nie einen Server.
Ja. Eine Formular-ID ist dafür gedacht, öffentlich zu sein, genau wie eine E-Mail-Adresse auf einer Kontaktseite. Schütze das Formular mit erlaubten Domains, dem Honeypot, Rate-Limits und der Menschlichkeitsprüfung. Was niemals in eine statische Site gehört, ist ein geheimer API-Schlüssel.
Er funktioniert nur, wenn der Besucher eine Mail-App eingerichtet hat, er speichert nichts, und er schreibt deine Adresse für Scraper in die Seite. Ein Formular hält den Besucher auf deiner Site und gibt dir einen Nachweis jeder Nachricht.
Nein. Statische Hosts führen für deine Seiten weder PHP noch anderen Servercode aus. Ein PHP-Mail-Skript braucht einen Server, und genau den vermeidet eine statische Site.
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.
Nein, es gibt keinen kostenlosen Tarif. Testen ist kostenlos: Du kannst 50 Testeinsendungen pro Monat von localhost senden, bevor du zahlst. Für den Livebetrieb startet Pro bei $6/Monat für 500 Einsendungen, oder du zahlst einmalig 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.