Kontaktformular für statische Astro-Seiten einbauen (ohne SSR)
Statische Astro-Seiten haben kein Backend, brauchen aber Formulare. So sieht mein Shared-Backend-Setup aus: spam-gefiltert, gespeichert, gemailt.
Kemal Esensoy·aktualisiert am August 17, 2026
Das ganze Versprechen von Astro ist: Es gibt nichts, was kaputtgehen kann. Du baust die Seite, sie wirft einen Ordner voll reinem HTML aus, du packst das hinter ein CDN, fertig. Kein Server, der um 3 Uhr nachts läuft. Nichts zu patchen. Nichts, was gehackt werden kann.
Dann schreibt der Kunde: "Können wir ein Kontaktformular einbauen?"
Und diese saubere Geschichte läuft gegen eine Wand. Ein Formular muss irgendwohin abschicken, und eine statische Seite hat kein Irgendwohin. Ich habe dasselbe Astro Kontaktformular inzwischen für einen ganzen Stapel Kundenseiten gebaut, und die ersten zwei Male habe ich es auf zwei verschiedene Arten falsch gemacht. Hier ist die Version, die mir jemand am ersten Tag hätte geben sollen.
Warum statische Astro-Seiten Kontaktformulare nicht selbst verarbeiten können
Ein Static Site Generator wie Astro rendert alles zur Build-Zeit in HTML. Da ist kein PHP, kein Node-Prozess, keine Datenbank, die hinter der Seite auf einen POST wartet. Genau deshalb ist es schnell, günstig und schwer angreifbar. Und genau deshalb bricht das Modell in dem Moment, in dem du Daten von einem Besucher empfangen willst.
Ein Formular ist per Definition dynamisch. Jemand tippt seinen Namen, klickt auf Absenden, und diese Daten müssen an einen Server, der sie validiert, speichert und dir mailt. Deine statische Seite kann dieser Server nicht sein. Sie ist nur Dateien.
Die Frage ist also nicht "wie bringe ich Astro dazu, ein Formular zu verarbeiten." Astro kann das nicht und soll das nicht. Die Frage ist "wo lebt der dynamische Teil, und wie schraube ich ihn dran, ohne den Grund wegzuwerfen, warum ich überhaupt statisch gewählt habe." Falls du noch zwischen statischem Astro, Next.js und etwas Server-gerendertem schwankst, habe ich separat aufgeschrieben, wie ich den Stack für jedes Kundenprojekt tatsächlich auswähle. Dieser Beitrag setzt voraus, dass du dich schon für statisch entschieden hast und jetzt ein Formular brauchst.
4 Wege, ein Kontaktformular in Astro einzubauen (und warum 3 davon nach hinten losgehen)
Fast jeder greift zu einem dieser vier. Drei davon habe ich in echten Projekten ausprobiert.
Ein Node- oder Express-Endpoint pro Seite. Läuft super für Seite Nummer eins. Bei Seite Nummer sechs pflegst du sechs getrennte Deployments, sechs Sätze Secrets, sechs Dinge zum Patchen, wenn eine Dependency ein CVE hat. Die Formular-Logik ist jedes Mal Byte für Byte identisch. Nur die Empfänger-Mail ändert sich. Infrastruktur zu kopieren ist keine Architektur, es ist eine Zeitbombe.
SSR einschalten, "nur für das Formular". Jetzt rendert deine komplette Seite auf einem Server. Du hast dir einen Adapter, eine Runtime, Cold Starts und eine deutlich größere Angriffsfläche eingehandelt, alles nur für eine Nutzlast, die ein paar Mal am Tag auftaucht. Du hast genau das zurückgegeben, was statisch überhaupt lohnenswert gemacht hat.
Ein Formular-SaaS wie Formspree oder Basin. Zum Start völlig in Ordnung, und für eine einzelne Seite würde ich niemandem davon abraten. Aber der Preis pro Formular summiert sich schnell über zehn Kundenseiten, und je nach Rechtslage und Sensibilität der Daten (ein Kreditlead oder eine juristische Anfrage ist nicht dasselbe wie eine Newsletter-Anmeldung) wird das Durchleiten durch einen Drittanbieter zu einem Datenschutz-Gespräch, das du vielleicht nicht führen willst. Aus demselben Grund habe ich viel aus meinem Stack von gemieteten Diensten weg und auf selbst gehostete Dinge verlagert.
Client-seitige Mail-Tools wie EmailJS. Deine Provider-Zugangsdaten landen direkt im Browser, wo jeder sie lesen kann. Lass es. Das ist die ganze Anmerkung.
Das Muster, das alle vier Probleme auf einmal umgeht: ein geteiltes Formular-Backend, viele statische Seiten.
Die Shared-Backend-Architektur: Ein Formular-Service für viele statische Seiten
So sieht es aus. Ein kleiner Service. Eine Datenbank. Jede Seite postet dorthin, mit ihrer eigenen ID in der URL.
[ site-a (statisch) ] --POST--> forms.example.com/submit/site-a/kontakt
[ site-b (statisch) ] --POST--> forms.example.com/submit/site-b/kontakt
forms.example.com (ein kleiner Service)
validieren -> spam-check -> speichern -> mailen
|
[ Postgres ] [ SMTP-Provider ]
Der Trick ist, drei Zuständigkeiten im Kopf sauber zu trennen:
- Render ist das HTML, das der Besucher sieht. Das ist pro Seite, es lebt im statischen Astro-Build.
- Transport ist, wie die Daten den Browser verlassen. Ein nativer POST oder ein
fetch. - Handle ist validieren, Spam prüfen, speichern, benachrichtigen. Das ist das geteilte Backend, einmal gelöst.
Render ändert sich pro Kunde. Transport und Handle baust du ein einziges Mal. Das ganze Backend sind ein paar hundert Zeilen mit einer einzigen interessanten Route:
POST /submit/:siteId/:formId
Der Mandant steht direkt in der URL, also hält der Service keinen Zustand darüber, welche Seite gerade anruft. Der Aufrufer deklariert es, der Service schlägt es in einer kleinen Config-Registry nach, verschlüsselt nach Site-ID, dann nach Form-ID:
export const sites = {
'site-a': {
allowedOrigins: ['https://site-a.example', 'https://www.site-a.example'],
fromEmail: 'Site A <no-reply@site-a.example>', // verifizierter Absender
forms: {
kontakt: {
recipients: ['leads@site-a.example'],
required: ['name', 'email', 'nachricht'],
altcha: true,
redirectSuccess: 'https://site-a.example/danke/',
},
},
},
};
Seite Nummer elf einzubinden ist ein Objekt in dieser Datei plus ein Redeploy. Der Code ändert sich nie. Diese Eigenschaft, neuer Mandant heißt neue Config-Zeile und nicht neues Deployment, ist der ganze Grund, warum dieser Ansatz den Copy-Paste-Ansatz schlägt.
Wie du Formular-Einsendungen speicherst, ohne Leads zu verlieren
Zwei Entscheidungen haben mir hier echten Schmerz erspart, also will ich konkret werden.
Erstens: modelliere keine feste Spalte pro Formularfeld. Ein Rückrufformular hat ein telefon-Feld, ein Kontaktformular hat nachricht, der nächste Kunde will ein Budget-Dropdown. Wenn jede Formularform eine Datenbank-Migration braucht, hasst du dein Leben spätestens beim dritten Kunden. Speichere stattdessen den Umschlag plus einen JSON-Blob:
CREATE TABLE submissions (
id bigserial PRIMARY KEY,
site_id text NOT NULL,
form_id text NOT NULL,
received_at timestamptz NOT NULL,
ip text,
user_agent text,
fields jsonb NOT NULL -- was auch immer das Formular geschickt hat
);
site_id beantwortet für immer "welche Seite war das", und jsonb heißt, ein neues Formular braucht null Migrationen. Abfragen kannst du trotzdem: fields->>'email', Index pro Seite, alles davon.
Zweitens, und das ist die Sache, die die Leute überspringen: speichere den Lead, bevor du versuchst zu mailen. Mail ist der wackelige Teil. SMTP-Aussetzer, ein Absender, den du zu verifizieren vergessen hast, ein Provider-Rate-Limit. Wenn dein Handler zuerst mailt und danach speichert, bedeutet ein Mail-Fehler einen verlorenen Kunden. Also dreh es um. Schreib in die Datenbank, und falls das irgendwie scheitert, häng die Einsendung an eine lokale NDJSON-Datei als Ausfallsicherung an. Erst dann versuch die Mail, und falls die scheitert, logge es laut, aber sag dem Besucher trotzdem, dass es geklappt hat, denn seine Daten sind schon sicher:
let stored = false;
try { await db.insert(submission); stored = true; }
catch (e) { console.error('DB insert failed, using file fallback', e); }
if (!stored) await appendNdjson(submission);
try { await sendMail(submission); }
catch (e) { console.error('mail FAILED (lead is stored)', e); }
return respondSuccess();
Ein falsch konfigurierter Absender wird zu einer Zeile in deinen Logs, nicht zu einem Kunden, der denkt, er hätte dich kontaktiert, und nie etwas hört. Dieser Fehlerfall ist unsichtbar und der schlimmste, weil sich niemand beschwert. Sie gehen einfach still zur Konkurrenz.
Wie du Kontaktformular-Spam ohne reCAPTCHA stoppst
Es gibt keinen einzelnen Zauberfilter. Was funktioniert, ist ein Stapel günstiger Schichten, jede killt eine Klasse von Bots, keine nervt einen echten Menschen.
- Origin-Lock. Der
Origin-Header der Anfrage muss in denallowedOriginsdieses Mandanten stehen. Seite A kann physisch nicht über die Config von Seite B spammen. - Honeypot. Ein verstecktes Feld, das ein echter Nutzer nie sieht. Kommt es ausgefüllt zurück, verwirf die Einsendung still und gib trotzdem Erfolg zurück. Sag dem Bot nie, dass er erwischt wurde, sonst passt er sich an.
- Rate-Limit. Sowas wie 20 Anfragen pro 10 Minuten pro IP. Ein Mensch schickt eine Nachricht. Eine Bot-Farm nicht.
- Proof-of-Work. Der Browser muss ein kleines bisschen CPU verbrennen, bevor der Submit akzeptiert wird. Trivial für einen ehrlichen Besucher, teuer im Spam-Maßstab.
Der letzte Punkt ist da, wo ich ALTCHA nutze, und es lohnt sich zu erklären, warum ich es Googles Tool vorgezogen habe. reCAPTCHA schleppt einen Drittanbieter mit rein, trackt deine Nutzer und gibt Google noch einen Blick in deinen Traffic. Ich bin in diesem Beitrag über reCAPTCHA-Alternativen tiefer eingestiegen, aber die Kurzfassung ist: ALTCHA ist datenschutzfreundlich, selbst hostbar und stellt keine Anforderung daran, wo dein DNS liegt. Für ein Kontaktformular sind sein SHA-256-Proof-of-Work plus Honeypot plus Rate-Limiting schon das richtige Maß an Abwehr.
ALTCHA in Astro einrichten: Der Versionskonflikt, der alles kaputt macht
Hier ist der, der einen ganzen Nachmittag gefressen hat, also lass mich dir den Nachmittag sparen.
ALTCHA kommt in zwei Hälften: ein Widget, das im Browser läuft, und eine Server-Library, die das gelöste Token verifiziert. Beide müssen dieselbe Protokoll-Version sprechen. Der klassische Proof-of-Work-Flow (Server-Library v1, Widget v1.x) legt ein gelöstes Token in ein verstecktes Feld und verifiziert es serverseitig. Das neuere v3-Widget erwartet das v2-Server-Protokoll, das eine andere Challenge-Form nutzt. Misch ein v3-Widget mit einem v1-Backend, und du bekommst das:
ALTCHA verification failed: Server responded with invalid content-type.
Expected application/json, received text/html.
Dieser Fehler ist zum Wahnsinnigwerden, weil dein Challenge-Endpoint tatsächlich JSON zurückgibt. Du kannst ihn curlen und zusehen, wie er JSON zurückgibt. Was wirklich passiert: das v3-Widget, dem eine v1-Challenge übergeben wird, die es nicht versteht, fällt auf einen Server-Verifizierungspfad gegen eine leere URL zurück, die auf deine eigene Seite auflöst, die HTML zurückgibt. Die Content-Type-Prüfung fliegt dann. Du verbringst drei Stunden damit, einen JSON-Endpoint zu debuggen, der nie das Problem war.
Die Lösung ist nicht, es zu debuggen. Die Lösung ist, die Versionen anzugleichen. Wenn dein Backend die v1-Library nutzt (simpel, perfekt für ein Kontaktformular), nutze das v1.x-Widget. Wenn du unbedingt v3s Argon2-Härtung willst, hebe das Backend auf die v2-Library. Und hier die Ermessensfrage: Argon2-Härtung existiert, um Credential-Stuffing auf Login-Formularen auszubremsen. Für Spam auf einem Kontaktformular ist das übertrieben. Greif nicht reflexartig nach "latest". Passe das Werkzeug an die Bedrohung an.
Noch eine Sache: hoste das Widget selbst. Vendore die Datei altcha.min.js direkt in deinen Astro-public/-Ordner. Kein CDN. Das hält deine Content-Security-Policy einfach, was im nächsten Abschnitt zählt.
Das Astro-Kontaktformular bauen: Komponente, CSP und Danke-Seite
Die statische Hälfte eines Astro-Kontaktformulars ist klein. Eine Formular-Komponente, das vendorte Widget, drei CSP-Zeilen und eine Danke-Seite.
---
const BACKEND = 'https://forms.example.com';
const formAction = `${BACKEND}/submit/site-a/kontakt`;
const altchaUrl = `${BACKEND}/altcha/challenge`;
---
<form method="post" action={formAction}>
<label>Name <input name="name" required /></label>
<label>E-Mail <input type="email" name="email" required /></label>
<label>Nachricht <textarea name="nachricht" required></textarea></label>
<!-- honeypot: off-screen, wird trotzdem gesendet -->
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
style="position:absolute;left:-9999px" aria-hidden="true" />
<label>
<input type="checkbox" name="datenschutz" required />
Ich habe die <a href="/datenschutz/">Datenschutzerklärung</a> gelesen.
</label>
<altcha-widget challengeurl={altchaUrl} auto="onsubmit"></altcha-widget>
<button type="submit">Senden</button>
</form>
<script is:inline type="module" src="/altcha.min.js"></script>
Ein paar Dinge dort sind tragend. Der name an jedem Input muss exakt zur required[]-Liste des Backends passen. Das auto="onsubmit" lässt das Widget sein Rätsel erst lösen, wenn der Nutzer wirklich absendet, also gibt es keine sichtbare Reibung, bis er handelt. Die DSGVO-Einwilligungs-Checkbox ist für jede deutsche oder EU-Seite Pflichtprogramm, sie ist client-seitig required und wird mit der Einsendung gespeichert. Und is:inline am Script-Tag hindert Astro daran, das vendorte Widget zu bundeln.
Dann die CSP-Zeilen, die jeder vergisst. Eine strikte Content-Security-Policy blockiert das Widget still, und du hast keine Ahnung warum. Merge diese in deine bestehende Policy:
connect-src 'self' https://forms.example.com;
form-action 'self' https://forms.example.com;
worker-src 'self' blob:;
connect-src lässt das Widget seine Challenge holen, form-action erlaubt den nativen Cross-Origin-POST, und worker-src lässt das Widget sein Proof-of-Work in einem Web Worker lösen. Wenn dein Formular "einfach nicht geht" und die Konsole eine CSP-Verletzung zeigt, ist es fast immer eine dieser drei. Ein externes script-src brauchst du nicht, weil du das Widget selbst gehostet hast.
Die Danke-Seite ist eine schlichte src/pages/danke/index.astro, markiert als noindex. Der native POST leitet bei Erfolg dorthin um. Nichts Ausgefallenes, nur die Bestätigung, dass die Nachricht angekommen ist.
Ein geteiltes Formular-Backend deployen: Die Stolperfallen, die wirklich beißen
Das sind die, die mich bei echten Deploys Zeit gekostet haben, nicht die theoretischen.
tsc: not found beim Docker-Build. Viele Plattformen injizieren NODE_ENV=production zur Build-Zeit, was npm ci die devDependencies überspringen lässt, also wird TypeScript nie installiert. Fix: npm ci --include=dev in der Builder-Stage.
Secrets in Image-Layer eingebacken. Wenn deine Plattform jede Env-Variable in ein Build-ARG verwandelt, werden deine Datenbank-URL und dein SMTP-Passwort ins Image eingebacken, und jeder, der es pullt, kann sie lesen. Markiere sie als runtime-only. Diese App braucht zur Build-Zeit nichts Geheimes. Genau das ist die Art Sache auf meiner Checkliste zur Website-Sicherheitshärtung, die Leute übersehen, bis sie beißt.
Eine gebundelte Datenbank statt einer gemanagten. Es ist verlockend, Postgres in die docker-compose der App zu werfen. Lass es. Ein verirrtes compose down -v oder ein unglücklicher Redeploy kann jeden Lead löschen, den du gesammelt hast, und jetzt sind Backups auch noch dein Problem. Nutze eine gemanagte Datenbank als separate Ressource und verbinde über DATABASE_URL. Der Lebenszyklus der App und der deiner Daten sollten nie dasselbe sein.
Cloudflare-Fehler 526. Wenn die Domain über Cloudflare im SSL-Modus "Full (strict)" proxyt, aber der Origin kein gültiges Zertifikat hat, gibt jede Anfrage ein 526. Entweder stellst du ein echtes Zertifikat am Origin aus oder du gehst runter auf "Full."
Wie du ein Kontaktformular an einem Nachmittag zu einer neuen Astro-Seite hinzufügst
Sobald das Backend existiert, hier die komplette Checkliste für Seite Nummer elf:
- Backend: füge die Seite zur Config-Registry hinzu, mit ihren Origins, Absenderadresse, Formularen und Empfängern. Committen, pushen, den einen Service redeployen.
- Seite: die Formular-Komponente reinlegen,
altcha.min.jsinpublic/vendoren, die drei CSP-Zeilen hinzufügen, eine/danke/-Seite anlegen. - Prüfen: builden, einmal in einem echten Browser absenden. Das Widget löst, du bekommst die Weiterleitung, die Mail kommt an, und die Zeile taucht in Postgres auf, getaggt mit der neuen
site_id.
Das war's. Der schwere Teil war, es einmal zu entwerfen. Jede Seite danach ist ein Nachmittag, und ehrlich gesagt ist der größte Teil dieses Nachmittags, das Formular an das Design der Seite anzupassen.
Statische Generierung und dynamische Formular-Verarbeitung standen nie wirklich im Widerspruch. Du steckst den dynamischen Teil einfach dahin, wo er hingehört, in einen kleinen geteilten Service, statt ihn über jede Seite zu schmieren oder eine ganze Runtime an deinen Renderer zu schrauben.
Wenn du eine Handvoll statischer Seiten betreibst und die Formular-Situation still zum Wartungskopfschmerz geworden ist, dann ist das genau das Problem, das ich gern löse. Erzähl mir, womit du dich rumschlägst, und ich sage dir ehrlich, ob sich dieses Setup für deinen Fall lohnt oder ob ein Formular-SaaS für den Moment noch in Ordnung ist.
Über den Autor
Kemal Esensoy
Kemal Esensoy, Gründer von Wunderlandmedia, begann seine Karriere als freiberuflicher Webentwickler und Designer. Er führte Web-Design-Kurse mit über 3.000 Studenten durch. Heute leitet er eine preisgekrönte Full-Stack-Agentur, die sich auf Webentwicklung, SEO und digitales Marketing spezialisiert hat.