Statische Seiten sind großartig: schnell, günstig zu hosten, sicher und denkbar einfach zu deployen. Dann fragt ein Kunde “können Besucher uns über die Seite eine E-Mail schicken?” und plötzlich stehst du vor genau der einen Sache, die eine statische Seite von sich aus nicht kann: eine Formularübermittlung entgegennehmen.
Die gute Nachricht: Du brauchst dafür kein Backend und keinen Server. Am Ende dieser Anleitung hast du ein voll funktionsfähiges, barrierefreies und spam-resistentes Kontaktformular, das du in reines HTML, React, Next.js, Vue, Astro oder Svelte einsetzen kannst, mit vollständigem Copy-Paste-Code statt Platzhaltern.
Die Optionen für Formulare ohne Backend
Es gibt im Grunde vier gängige Ansätze, und sie sind nicht gleichwertig.
1. mailto:-Links. Null Aufwand. <a href="mailto:du@example.com"> öffnet das E-Mail-Programm des Besuchers. Aber die Erfahrung ist holprig: Es setzt ein eingerichtetes Desktop-Mailprogramm voraus, stellt deine Adresse für Scraper offen ins Netz und erfasst nichts Strukturiertes. Für eine persönliche Landingpage okay, für echten Kontakt nicht.
2. Google-Forms-Einbettung. Kostenlos und ohne Code, aber der iframe sieht überhaupt nicht nach deiner Seite aus, arbeitet gegen dein Design und deine Performance, und die UX schreit “das ist ein Google-Formular”. Für ein Hobbyprojekt in Ordnung, schwach für alles, was professionell wirken soll.
3. Formular-Handling des Hosters. Netlify Forms und ein paar andere Hoster bieten eingebaute Formularerfassung. Wirklich schön, wenn du bei diesem Hoster bist. Sobald du zu GitHub Pages, Cloudflare Pages oder in deinen eigenen S3-Bucket wechselst, ist es weg. Du bist eingesperrt.
4. Eine Formular-Backend-API. Du richtest dein <form> auf einen Endpunkt; der Dienst nimmt Einsendungen entgegen, filtert Spam und schickt dir eine E-Mail (oder feuert einen Webhook). Hoster-unabhängig, funktioniert mit jedem Framework, und dein Markup bleibt zu 100 % deins. Das ist der Ansatz, der überall funktioniert, also bauen wir den.
Schritt 1: Das HTML-Formular (von Haus aus barrierefrei)
Hier ein vollständiges, barrierefreies Formular. Achte auf die echten <label>-Elemente und die for/id-Paarung, genau der Teil, den die meisten Tutorials überspringen.
<form
action="https://api.formpaste.com/submit"
method="POST"
id="contact-form"
>
<!-- Dein Access Key aus dem Dashboard -->
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY_HERE" />
<div>
<label for="name">Name</label>
<input type="text" id="name" name="name" autocomplete="name" required />
</div>
<div>
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" autocomplete="email" required />
</div>
<div>
<label for="message">Nachricht</label>
<textarea id="message" name="message" rows="5" required></textarea>
</div>
<button type="submit">Nachricht senden</button>
</form>
Das ist bereits ein funktionierendes Formular. Ganz ohne JavaScript wird eine Einsendung an den Endpunkt geschickt und landet in deinem Postfach. Progressive Enhancement, wie es sein soll: Es funktioniert, bevor eine einzige Zeile JS läuft.
Standardmäßig antwortet der Endpunkt mit JSON. Ohne JavaScript auf der Seite zeigt der Browser diese JSON-Antwort einfach an, was nicht die Erfahrung ist, die du willst. Wie du den Besucher stattdessen auf eine Danke-Seite schickst, steht in Schritt 4.
Schritt 2: Mit JavaScript aufwerten (saubere Lade- und Fehlerzustände)
Das einfache Formular löst eine komplette Seitennavigation aus. Damit Nutzer auf der Seite bleiben und Feedback direkt sehen, werten wir es auf. Beachte: Das hier wählt das Formular tatsächlich über seine id aus, behandelt den Netzwerkfehlerfall und gibt echtes Status-Feedback. Das sind die Details, die ein funktionierendes Formular von einer Demo unterscheiden.
<form action="https://api.formpaste.com/submit" method="POST" id="contact-form">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY_HERE" />
<input type="text" id="name" name="name" required />
<input type="email" id="email" name="email" required />
<textarea id="message" name="message" required></textarea>
<button type="submit">Nachricht senden</button>
<p id="form-status" role="status" aria-live="polite"></p>
</form>
<script>
const form = document.getElementById("contact-form");
const statusEl = document.getElementById("form-status");
const button = form.querySelector('button[type="submit"]');
form.addEventListener("submit", async (e) => {
e.preventDefault();
button.disabled = true;
statusEl.textContent = "Wird gesendet…";
try {
const response = await fetch(form.action, {
method: "POST",
body: new FormData(form),
});
if (response.ok) {
statusEl.textContent = "Danke! Deine Nachricht wurde gesendet.";
form.reset();
} else {
// Die API liefert bei Fehlern { success: false, code, message }.
const data = await response.json().catch(() => null);
statusEl.textContent = data?.message ?? "Da ist etwas schiefgelaufen. Bitte versuche es erneut.";
}
} catch (error) {
statusEl.textContent = "Netzwerkfehler. Bitte versuche es erneut.";
} finally {
button.disabled = false;
}
});
</script>
Der Bereich mit aria-live="polite" sorgt dafür, dass Screenreader die Statusänderung vorlesen, ein geschenkter Gewinn an Barrierefreiheit.
Eine erfolgreiche Antwort ist JSON in dieser Form:
{ "success": true, "message": "Submission received.", "id": "..." }
Fehler nutzen denselben Umschlag mit einem maschinenlesbaren Code, du kannst also auf data.code verzweigen, wenn du für rate_limited und domain_not_allowed unterschiedliche Meldungen zeigen willst.
Schritt 3: Framework-Beispiele (vollständig, keine Platzhalter)
React
import { useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState("idle"); // idle | sending | success | error
async function handleSubmit(e) {
e.preventDefault();
// Formular-Node jetzt festhalten: nach dem ersten await ist `currentTarget` null.
const form = e.currentTarget;
setStatus("sending");
try {
const response = await fetch("https://api.formpaste.com/submit", {
method: "POST",
body: new FormData(form),
});
setStatus(response.ok ? "success" : "error");
if (response.ok) form.reset();
} catch {
setStatus("error");
}
}
return (
<form onSubmit={handleSubmit}>
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY_HERE" />
<label htmlFor="name">Name</label>
<input id="name" type="text" name="name" required />
<label htmlFor="email">E-Mail</label>
<input id="email" type="email" name="email" required />
<label htmlFor="message">Nachricht</label>
<textarea id="message" name="message" required />
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Wird gesendet…" : "Nachricht senden"}
</button>
{status === "success" && <p role="status">Danke! Deine Nachricht wurde gesendet.</p>}
{status === "error" && <p role="status">Da ist etwas schiefgelaufen. Bitte erneut versuchen.</p>}
</form>
);
}
Next.js (App Router)
Mit dem App Router hältst du das Formular als Client-Komponente, damit du den Sende-Status verwalten kannst. Einen Route Handler brauchst du gar nicht, denn das Formular postet direkt an die API.
Sagen wir es deutlich: Der Access Key steht im Client-Code und ist für jeden im Seitenquelltext sichtbar. Das ist bei dieser Art Endpunkt so gewollt. Der Key sagt nur, zu welchem Formular eine Einsendung gehört; er ist kein geheimes Passwort und man kann damit deine Einsendungen nicht auslesen. Dein Schutz dagegen, dass ihn jemand woanders wiederverwendet, ist die Domain-Allowlist, dazu weiter unten mehr.
"use client";
import { useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
async function handleSubmit(e) {
e.preventDefault();
const form = e.currentTarget; // vor dem await festhalten
setStatus("sending");
try {
const res = await fetch("https://api.formpaste.com/submit", {
method: "POST",
body: new FormData(form),
});
setStatus(res.ok ? "success" : "error");
if (res.ok) form.reset();
} catch {
setStatus("error");
}
}
return (
<form onSubmit={handleSubmit}>
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY_HERE" />
<input type="text" name="name" placeholder="Name" required />
<input type="email" name="email" placeholder="E-Mail" required />
<textarea name="message" placeholder="Nachricht" required />
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Wird gesendet…" : "Nachricht senden"}
</button>
{status === "success" && <p role="status">Nachricht gesendet!</p>}
{status === "error" && <p role="status">Bitte versuche es erneut.</p>}
</form>
);
}
Vue 3
Dieses Beispiel postet JSON statt FormData, einfach um zu zeigen, dass der Endpunkt beides akzeptiert. Weil die Werte aus dem v-model-State kommen, wandert der Access Key in den JSON-Body und es gibt kein verstecktes Input-Feld.
<template>
<form @submit.prevent="submitForm">
<label for="name">Name</label>
<input id="name" v-model="form.name" type="text" name="name" required />
<label for="email">E-Mail</label>
<input id="email" v-model="form.email" type="email" name="email" required />
<label for="message">Nachricht</label>
<textarea id="message" v-model="form.message" name="message" required />
<button type="submit" :disabled="status === 'sending'">
{{ status === "sending" ? "Wird gesendet…" : "Nachricht senden" }}
</button>
<p v-if="status === 'success'" role="status">Danke! Nachricht gesendet.</p>
<p v-if="status === 'error'" role="status">Da ist etwas schiefgelaufen.</p>
</form>
</template>
<script setup>
import { reactive, ref } from "vue";
const ACCESS_KEY = "YOUR_ACCESS_KEY_HERE";
const form = reactive({ name: "", email: "", message: "" });
const status = ref("idle");
async function submitForm() {
status.value = "sending";
try {
const res = await fetch("https://api.formpaste.com/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, access_key: ACCESS_KEY }),
});
status.value = res.ok ? "success" : "error";
if (res.ok) Object.assign(form, { name: "", email: "", message: "" });
} catch {
status.value = "error";
}
}
</script>
Astro
Astro liefert standardmäßig null JS aus, das einfache HTML-Formular funktioniert in einer .astro-Datei also unverändert.
---
// src/components/ContactForm.astro
const ACCESS_KEY = "YOUR_ACCESS_KEY_HERE";
---
<form action="https://api.formpaste.com/submit" method="POST" id="contact-form">
<input type="hidden" name="access_key" value={ACCESS_KEY} />
<input type="hidden" name="redirect" value="/danke" />
<label for="name">Name</label>
<input id="name" type="text" name="name" required />
<label for="email">E-Mail</label>
<input id="email" type="email" name="email" required />
<label for="message">Nachricht</label>
<textarea id="message" name="message" required></textarea>
<button type="submit">Nachricht senden</button>
</form>
Weil hier kein JavaScript läuft, ist das redirect-Feld wichtig: Ohne es würde der Browser auf der rohen JSON-Antwort landen. Wenn du lieber Feedback direkt auf der Seite willst, lass das redirect-Feld weg und ergänze ein Client-Skript:
<script>
const form = document.getElementById("contact-form");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const res = await fetch(form.action, {
method: "POST",
body: new FormData(form),
});
form.insertAdjacentHTML(
"beforeend",
res.ok ? "<p role='status'>Nachricht gesendet!</p>" : "<p role='status'>Bitte versuche es erneut.</p>",
);
if (res.ok) form.reset();
});
</script>
Svelte
<script>
let status = "idle";
async function handleSubmit(event) {
const form = event.currentTarget; // vor dem await festhalten
status = "sending";
try {
const res = await fetch("https://api.formpaste.com/submit", {
method: "POST",
body: new FormData(form),
});
status = res.ok ? "success" : "error";
if (res.ok) form.reset();
} catch {
status = "error";
}
}
</script>
<form on:submit|preventDefault={handleSubmit}>
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY_HERE" />
<input type="text" name="name" placeholder="Name" required />
<input type="email" name="email" placeholder="E-Mail" required />
<textarea name="message" placeholder="Nachricht" required></textarea>
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Wird gesendet…" : "Nachricht senden"}
</button>
{#if status === "success"}<p role="status">Nachricht gesendet!</p>{/if}
{#if status === "error"}<p role="status">Bitte versuche es erneut.</p>{/if}
</form>
Schritt 4: Redirect oder AJAX, wähle deinen Erfolgsfall
Es gibt zwei Wege, eine Einsendung zu bestätigen, und die Wahl trifft der Request selbst, nicht ein Header.
JSON (der Standard). Du postest das Formular und bekommst { success, message, id } mit einem 200 zurück. Genau darauf bauen alle JavaScript-Beispiele oben: Submit abfangen, fetch, Meldung direkt anzeigen, die Seite nie verlassen.
Redirect. Ergänze ein redirect-Feld im Formular, und eine erfolgreiche Einsendung antwortet mit einem 303 See Other auf diese URL, sodass der Browser auf deiner Danke-Seite landet. Das ist der Weg ohne JS.
<input type="hidden" name="redirect" value="/danke" />
Zwei Regeln solltest du kennen, denn beide greifen im Zweifel restriktiv:
- Eine relative URL wie
/dankefunktioniert immer. - Eine absolute URL wie
https://example.com/dankefunktioniert nur, wenn diese Domain auf der Allowlist deines Formulars steht. Bei leerer Allowlist werden absolute Redirects grundsätzlich abgelehnt. Das ist Absicht: Es verhindert, dass ein gestohlener Key deine Besucher auf eine Phishing-Seite umleitet.
Ein ungültiger Redirect liefert 400 invalid_redirect und es wird nichts gespeichert, du merkst es also sofort, statt still Einsendungen zu verlieren.
Nimm den Redirect für maximale Einfachheit und Robustheit; nimm JSON für das flüssigere Single-Page-Gefühl.
Spam stoppen ohne CAPTCHA
CAPTCHA-Rätsel nerven echte Nutzer und kosten Conversions. Den meisten Bot-Spam stoppst du mit drei unauffälligen Techniken, ganz ohne Rätsel.
Honeypot-Feld: ein verstecktes Input, das echte Nutzer nie ausfüllen, Bots aber schon. Blende es vor Menschen und Screenreadern aus und weise jede Einsendung ab, bei der es gefüllt ist. Der Feldname muss zu dem passen, wonach dein Anbieter sucht. Formpaste nutzt botcheck:
<!-- Bots füllen das aus; Menschen sehen es nie -->
<input
type="text"
name="botcheck"
style="position:absolute; left:-9999px;"
tabindex="-1"
autocomplete="off"
aria-hidden="true"
/>
Wenn du dir einen eigenen Feldnamen ausdenkst, weiß der Server nichts von einem Honeypot und speichert das Feld einfach als ganz normales Formularfeld. Schau in der Doku deines Anbieters nach dem exakten Namen; das ist ein stiller Fehler, der sich erst später als Spam im Postfach zeigt.
Zeitprüfung: Echte Nutzer brauchen ein paar Sekunden zum Ausfüllen; Bots senden in Millisekunden ab. Halte fest, wann das Formular geladen wurde, und markiere Einsendungen, die verdächtig schnell eintreffen. Formpaste liest das aus einem _ts-Feld mit dem Zeitstempel des Seitenaufrufs:
<input type="hidden" name="_ts" id="form-ts" />
<script>
document.getElementById("form-ts").value = Date.now();
</script>
Rate Limiting: Begrenze Einsendungen pro IP und Minute, damit ein Bot deinen Endpunkt nicht überrennen kann. Das hier braucht tatsächlich die Serverseite.
Alle drei selbst zu bauen bedeutet, Serverlogik zu betreiben, und das macht das Ziel “kein Backend” zunichte. Genau hier verdient sich eine Formular-Backend-API ihr Geld: Gute Anbieter übernehmen Honeypot, Zeitprüfung, Rate Limiting, Wegwerf-E-Mail- und Dubletten-Checks für dich und halten alles Verdächtige in einer Quarantäne, die du prüfen kannst, statt echte Nachrichten stillschweigend wegzuwerfen.
Häufige Fehler (und wie du sie behebst)
“Mein Formular sendet, aber ich bekomme keine E-Mail.” Prüfe deinen Spam-Ordner, kontrolliere, ob du deine Absenderadresse bestätigt hast, und stelle sicher, dass der Wert von access_key stimmt und nicht noch der Platzhalter ist.
“Ich bekomme ein 403 domain_not_allowed.” Dein Formular hat eine Domain-Allowlist und der Request kam von einer Domain, die nicht darauf steht. Trag die Domain im Dashboard ein. Wichtig: Das ist eine normale JSON-Fehlerantwort, kein CORS-Fehler.
“CORS-Fehler in der Konsole.” Das ist ein anderes Problem als das gerade genannte, und es lohnt sich, beide auseinanderzuhalten. Ein Formular-Endpunkt für statische Seiten sollte großzügige CORS-Header senden, ein echter CORS-Fehler bedeutet also meist, dass der Request die API überhaupt nicht erreicht hat: eine vertippte Endpunkt-URL, ein Netzwerk- oder DNS-Fehler oder ein Adblocker. Schau im Netzwerk-Tab nach, ob überhaupt eine Antwort zurückkam. Außerdem gut zu wissen: CORS ist eine Browser-Richtlinie, keine Sicherheitsgrenze. Es schützt deinen Key nicht, und was tatsächlich einschränkt, wer senden darf, ist die Domain-Allowlist.
“Beim Absenden passiert nichts.” Wenn du die JS-Variante nutzt, prüfe, ob dein getElementById/Selektor wirklich das Formular trifft und ob du e.preventDefault() aufgerufen hast.
“Lokal funktioniert es, in Produktion nicht.” Fast immer die Domain-Allowlist. Trag deine Produktions-Domain ein.
“e.currentTarget ist null.” Der klassische Async-Stolperstein: currentTarget wird geleert, sobald der Handler zurückkehrt, ein Zugriff nach einem await fliegt dir also um die Ohren. Halte es vorher in einer Variablen fest, so wie in den Beispielen oben.
Den passenden Dienst wählen
Ein kurzer, ehrlicher Überblick:
Formspree ist ausgereift und beliebt, mit einem großzügigen Ökosystem. Eine sichere Standardwahl, auch wenn der kostenlose Tarif begrenzt ist. Web3Forms kommt wirklich ohne Anmeldung aus und arbeitet mit Access Keys, super für schnelle Wegwerf-Formulare. Formpaste (was ich hier verwendet habe) setzt auf Copy-Paste-Einfachheit, Spam-Filterung ohne CAPTCHA mit einer Quarantäne, die du kontrollierst, und, ungewöhnlich, einen MCP-Server, damit ein KI-Coding-Agent das Formular für dich verkabeln kann. Netlify Forms ist ausgezeichnet, aber eben nur, wenn du ohnehin bei Netlify hostest.
Jeder davon funktioniert mit allen Codebeispielen oben. Tausche Endpunkt und Key aus und schau in der Doku des jeweiligen Anbieters nach dessen Feldnamen, denn Honeypot- und Redirect-Konventionen unterscheiden sich zwischen den Diensten.
Fazit
Ein Kontaktformular in eine einfache statische HTML-Seite einzubauen bedeutet nicht mehr, einen Server aufzusetzen. Du hast jetzt ein vollständiges, barrierefreies, progressiv aufgewertetes Formular, funktionierende Varianten für fünf Frameworks, eine Spam-Strategie ohne CAPTCHA und eine Liste mit Lösungen für die üblichen Stolperfallen.
Fang mit dem einfachen HTML-Formular an, werte es mit JavaScript auf, wenn du Feedback direkt auf der Seite willst, und überlass Zustellung und Spam einer Formular-Backend-API, damit du zurück ans Bauen kommst.