Typsichere Formularverarbeitung in Astro mit… | LaunchFast
LaunchFast LogoLaunchFast
Blog
2.970 Wörter15 Min. Lesezeit

Typsichere Formularverarbeitung in Astro mit Actions: Validierung, Spam-Schutz und Datei-Uploads

Erstellen Sie produktionsreife Kontakt-, Wartelisten- und Datei-Upload-Formulare in Astro mit Actions: serverseitige Zod-Validierung, Turnstile-Bot-Schutz, Cloudflare D1 und R2 sowie Resend, ganz ohne separates Backend.

Rishi Raj Jain
Rishi Raj JainAutor
Typsichere Formularverarbeitung in Astro mit Actions

Fast jede Website braucht ein Formular (ein Kontaktfeld, eine Warteliste, einen Datei-Upload), und schon wächst ein Formular zu einem Backend heran. Sie beginnen mit einem HTML-<form>, dann brauchen Sie serverseitige Validierung, dann Spam-Schutz, dann einen Ort zum Speichern der Daten, dann eine transaktionale E-Mail.

Astro Actions ermöglichen es Ihnen, eine typsichere Serverfunktion zu erstellen, die Sie direkt aus dem Browser wie eine lokale Funktion aufrufen. Astro übernimmt die Serialisierung, führt die Zod-Validierung auf dem Server aus und liefert Ihnen typisierte Ergebnisse und typisierte Fehler. In dieser Anleitung erstellen Sie drei Formulare mit Actions: ein Kontaktformular, eine Warteliste und einen Datei-Upload, die alle auf der Cloudflare-Workers-Laufzeit mit Turnstile, Cloudflare D1, Cloudflare R2 und Resend laufen.

Voraussetzungen

Sie benötigen Folgendes:

Warum Astro Actions für Formulare

Vor Actions bedeutete die Verarbeitung eines Formulars in Astro, einen API-Endpunkt zu erstellen, FormData von Hand zu parsen, es manuell zu validieren und Typen zwischen Client und Server zu duplizieren. Actions ersetzen all das durch eine einzige Definition, die:

  • Durchgängig typsicher: Eingabeschema und Rückgabewert werden auf beiden Seiten abgeleitet. Wenn Sie das Schema ändern, schlägt die Typprüfung des Client-Aufrufs fehl.
  • Serverseitig validiert: Die Eingabe wird mit Zod (importiert aus astro/zod) geparst, bevor Ihr Handler ausgeführt wird, sodass ungültige Eingaben Ihre Logik nie erreichen.
  • Direkt aufrufbar: Aus einem <script> rufen Sie actions.contact(formData) auf und erhalten { data, error } zurück. Validierungsfehler auf Feldebene kommen strukturiert zurück, sodass Sie sie inline anzeigen können.
  • Progressive-Enhancement-freundlich: Actions akzeptieren natives FormData, sodass derselbe Handler funktioniert, egal ob Sie mit JavaScript absenden oder auf einen einfachen HTML-Formular-Post zurückfallen.

Der Reiz liegt in der Ergonomie eines Funktionsaufrufs bei gleichzeitiger Sicherheit eines echten Backends.

Cloudflare Turnstile einrichten

Turnstile ist Cloudflares kostenlose, datenschutzfreundliche CAPTCHA-Alternative, die Bots und Spam aus Ihren Formularen fernhält, ohne echte Nutzer Rätsel lösen zu lassen.

Richten Sie es ein, bevor Sie Code schreiben, denn Sie brauchen die Schlüssel gleich:

  1. Öffnen Sie im Cloudflare-Dashboard Application Security > Turnstile und wählen Sie Add widget manually.
  2. Geben Sie ihm einen Namen und fügen Sie Ihre Domains hinzu, einschließlich localhost für die lokale Entwicklung.
  3. Belassen Sie den Modus auf Managed, sodass Cloudflare entscheidet, wann ein Besucher herausgefordert wird.
  4. Kopieren Sie die beiden generierten Schlüssel: den Site Key (öffentlich, im Browser verwendet) und den Secret Key (privat, für die serverseitige Verifizierung).

Halten Sie beide für die Umgebungsdateien später bereit.

Eine neue Astro-Anwendung erstellen

Legen wir los und erstellen ein neues Astro-Projekt. Führen Sie den folgenden Befehl aus:

Terminal window
npm create astro@latest my-astro-forms-app

npm create astro ist der empfohlene Weg, um schnell ein Astro-Projekt zu erstellen.

Wählen Sie bei den Abfragen:

  • Empty, wenn Sie gefragt werden, wie Sie das neue Projekt starten möchten.
  • Yes, wenn Sie gefragt werden, ob Sie TypeScript schreiben möchten.
  • Strict, wenn Sie gefragt werden, wie streng TypeScript sein soll.
  • Yes, wenn Sie gefragt werden, ob Abhängigkeiten installiert werden sollen.
  • Yes, wenn Sie gefragt werden, ob ein Git-Repository initialisiert werden soll.

Wechseln Sie dann in das Projektverzeichnis:

Terminal window
cd my-astro-forms-app

Den Cloudflare-Adapter für SSR hinzufügen

Actions werden bei Bedarf (on demand) gerendert, daher benötigt das Projekt den Cloudflare-Adapter. Er stellt außerdem D1, R2 und Turnstile in derselben Workers-Laufzeit bereit.

Terminal window
npx astro add cloudflare

Das richtet astro.config.mjs ein. Dieses Beispiel hält es minimal:

astro.config.mjs
import { defineConfig } from 'astro/config'
import cloudflare from '@astrojs/cloudflare'
export default defineConfig({
adapter: cloudflare({
imageService: 'passthrough',
}),
})

Cloudflare D1 und R2 binden

Erstellen Sie den Speicher, den die Formulare benötigen: eine D1-Datenbank für die Warteliste und einen R2-Bucket für Uploads:

Terminal window
npx wrangler d1 create forms
npx wrangler r2 bucket create forms-uploads

wrangler d1 create und wrangler r2 bucket create fügen die Bindings (und die database_id) für Sie zu wrangler.jsonc hinzu und erstellen die Datei, falls sie nicht existiert. Das Ergebnis sieht so aus:

wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "my-astro-forms-app",
"compatibility_date": "2026-07-27",
"compatibility_flags": ["global_fetch_strictly_public"],
"main": "@astrojs/cloudflare/entrypoints/server",
"assets": { "directory": "./dist", "binding": "ASSETS" },
"observability": { "enabled": true },
"d1_databases": [
{
"binding": "forms",
"database_name": "forms",
"database_id": "<your-d1-database-id>",
"remote": true
}
],
"r2_buckets": [
{
"bucket_name": "forms-uploads",
"binding": "forms_uploads",
"remote": true
}
]
}

Das Flag remote: true weist Wrangler an, Ihre echten D1- und R2-Ressourcen auch während astro dev zu verwenden, führen Sie also einmal npx wrangler login aus, bevor Sie starten.

Geheimnisse bleiben außerhalb von wrangler.jsonc. Legen Sie sie für die lokale Entwicklung in .dev.vars ab (Wrangler lädt die Datei automatisch während astro dev):

.dev.vars
RESEND_API_KEY="re_..."
CONTACT_FROM_EMAIL="Acme <onboarding@resend.dev>"
# Turnstile "always passes" test secret (swap for your real secret in production).
TURNSTILE_SECRET_KEY="1x0000000000000000000000000000000AA"

Und legen Sie den öffentlichen Turnstile-Site-Key in .env ab, damit er in den Client eingebettet werden kann:

.env
PUBLIC_TURNSTILE_SITE_KEY="1x00000000000000000000AA"

Erstellen Sie schließlich die Tabelle waitlist:

-- schema.sql
CREATE TABLE IF NOT EXISTS waitlist (
email TEXT PRIMARY KEY,
created_at TEXT NOT NULL
);
Terminal window
npx wrangler d1 execute forms --remote --file=./schema.sql

Die Laufzeitumgebung typisieren

Actions lesen Bindings und Geheimnisse aus dem Modul cloudflare:workers. Generieren Sie die Typen dafür direkt aus Ihrer wrangler.jsonc, damit env vollständig typisiert ist:

Terminal window
npx wrangler types

Das schreibt eine worker-configuration.d.ts, die Ihre forms- und forms_uploads-Bindings zusammen mit den Geheimnissen beschreibt. Installieren Sie nun Resend und Sie können mit dem Schreiben von Actions beginnen:

Terminal window
npm install resend

Ein typsicheres Kontaktformular erstellen

Actions werden in src/actions/index.ts definiert und aus einem einzigen server-Objekt exportiert. Beginnen wir mit dem Kontaktformular.

Die Kontakt-Action definieren

Jede Action deklariert, wie sie Eingaben akzeptiert ('form' für FormData), ein input-Zod-Schema und einen handler. Das Schema ist die Validierung: Was nicht passt, erreicht Ihren Handler nie, und der Fehler wird als strukturierte Feldfehler an den Client zurückgegeben. Bindings und Geheimnisse stammen aus dem env von cloudflare:workers, und der Resend-Client wird einmalig im Modul-Scope erstellt:

src/actions/index.ts
import { ActionError, defineAction } from 'astro:actions'
import { z } from 'astro/zod'
import { Resend } from 'resend'
import { env } from 'cloudflare:workers'
const resend = new Resend(env.RESEND_API_KEY)
export const server = {
contact: defineAction({
accept: 'form',
input: z.object({
name: z.string().trim().min(1, 'Please enter your name.'),
email: z.string().trim().email('Enter a valid email address.'),
message: z.string().trim().min(10, 'Your message should be at least 10 characters.'),
// Injected by the Turnstile widget as a hidden field.
'cf-turnstile-response': z.string().min(1, 'Please complete the anti-bot check.'),
}),
handler: async (input, context) => {
// ...verify Turnstile, then send the email (below)
return { ok: true }
},
}),
}

Bots mit Turnstile blockieren

Ein öffentliches Kontaktformular ist ein Spam-Magnet. Cloudflare Turnstile bietet Ihnen eine datenschutzfreundliche CAPTCHA-Alternative. Das Widget fügt dem Formular ein cf-turnstile-response-Token hinzu, das Sie auf dem Server verifizieren. Fügen Sie eine kleine Hilfsfunktion hinzu und rufen Sie sie zuerst im Handler auf:

/**
* Verify a Cloudflare Turnstile token server-side.
* Docs: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
*/
async function verifyTurnstile(token: string, secret: string, ip?: string) {
const body = new FormData()
body.append('secret', secret)
body.append('response', token)
if (ip) body.append('remoteip', ip)
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body,
})
const data = (await res.json()) as { success: boolean }
return data.success
}

Mit ActionError können Sie mit einem passenden HTTP-artigen Code fehlschlagen, den der Client von einem Validierungsfehler unterscheiden kann:

// inside the contact handler:
const ip = context.request.headers.get('CF-Connecting-IP') ?? undefined
const passed = await verifyTurnstile(input['cf-turnstile-response'], env.TURNSTILE_SECRET_KEY, ip)
if (!passed) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'Anti-bot verification failed. Please try again.',
})
}
// ...send email

Die E-Mail mit Resend versenden

Wenn die Anfrage validiert und verifiziert ist, stellen Sie sie zu. Das Resend-SDK läuft problemlos auf der Workers-Laufzeit:

const { error } = await resend.emails.send({
from: env.CONTACT_FROM_EMAIL,
to: input.email,
subject: `New contact message from ${input.name}`,
text: `From: ${input.name} <${input.email}>\n\n${input.message}`,
})
if (error) {
throw new ActionError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Could not send your message. Please try again later.',
})
}
return { ok: true }

Hier geht die Bestätigung an die im Formular angegebene Adresse (input.email). Um Kontaktnachrichten stattdessen an Ihren eigenen Posteingang zu leiten, senden Sie to an Ihre Adresse und setzen replyTo: input.email.

Eine Warteliste mit Cloudflare D1

Die Warteliste speichert E-Mails in D1 über das env.forms-Binding. Das interessante Detail ist die Idempotenz: Da email der Primärschlüssel ist, löst eine erneute Anmeldung einen UNIQUE-Constraint-Fehler aus, den wir als Erfolg behandeln.

joinWaitlist: defineAction({
accept: 'form',
input: z.object({
email: z.string().trim().toLowerCase().email('Enter a valid email address.'),
}),
handler: async (input, context) => {
try {
await env.forms
.prepare('INSERT INTO waitlist (email, created_at) VALUES (?, ?)')
.bind(input.email, new Date().toISOString())
.run()
} catch (err) {
// A UNIQUE violation just means they already signed up, so treat it as success.
if (!String(err).includes('UNIQUE')) {
throw new ActionError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Could not join the waitlist. Please try again.',
})
}
}
await resend.emails.send({
from: env.CONTACT_FROM_EMAIL,
to: input.email,
subject: "You're on the waitlist 🎉",
text: "Thanks for joining! We'll email you the moment we launch.",
})
return { ok: true, email: input.email }
},
}),

Beachten Sie z.string().trim().toLowerCase(). Zod normalisiert die Eingabe als Teil der Validierung, sodass Ada@Example.com und ada@example.com keine zwei Zeilen erzeugen.

Datei-Uploads zu Cloudflare R2 verarbeiten

Bei selbstgebauten Uploads müssen Sie Multipart-Bodies parsen, die Datei validieren und sie in den Speicher streamen. Mit Actions kommt die Datei als standardmäßiges File an, und Sie validieren sie mit demselben Zod-Schema-Stil wie alles andere. Der R2-Bucket ist das env.forms_uploads-Binding.

upload: defineAction({
accept: 'form',
input: z.object({
file: z
.instanceof(File, { message: 'Please choose a file to upload.' })
.refine((f) => f.size > 0, 'The selected file is empty.')
.refine((f) => f.size <= 5 * 1024 * 1024, 'File must be 5 MB or smaller.')
.refine(
(f) => ['image/png', 'image/jpeg', 'application/pdf'].includes(f.type),
'Only PNG, JPEG, or PDF files are allowed.',
),
}),
handler: async (input, context) => {
const ext = input.file.name.split('.').pop()?.toLowerCase() ?? 'bin'
const key = `uploads/${crypto.randomUUID()}.${ext}`
await env.forms_uploads.put(key, await input.file.arrayBuffer(), {
httpMetadata: { contentType: input.file.type },
})
return { ok: true, key, size: input.file.size }
},
}),

Typ und Größe vor dem Zugriff auf R2 zu validieren bedeutet, dass Sie einen 4-GB-Upload oder eine getarnte ausführbare Datei am Rand Ihres Handlers ablehnen.

Die Formulare im Client verdrahten

Nun das Frontend. Laden Sie das Turnstile-Skript, rendern Sie die drei Formulare und rufen Sie die Actions aus einem <script> auf. Der Client-Import astro:actions liefert Ihnen ein typisiertes actions-Objekt und eine isInputError-Hilfsfunktion, um Validierungsmeldungen auf Feldebene herauszuziehen.

src/pages/index.astro
---
const siteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY
---
<form id="contact">
<h2>Contact</h2>
<label>Name <input name="name" required /></label>
<label>Email <input name="email" type="email" required /></label>
<label>Message <textarea name="message" rows="4" required></textarea></label>
<div class="cf-turnstile" data-sitekey={siteKey}></div>
<button type="submit">Send message</button>
<p class="status" role="status"></p>
</form>
<form id="waitlist">
<h2>Join the waitlist</h2>
<label>Email <input name="email" type="email" required /></label>
<button type="submit">Join</button>
<p class="status" role="status"></p>
</form>
<form id="upload">
<h2>Upload a file</h2>
<label>File (PNG, JPEG, or PDF, max 5 MB) <input name="file" type="file" required /></label>
<button type="submit">Upload</button>
<p class="status" role="status"></p>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" />
<script>
import { actions, isInputError } from 'astro:actions'
const forms: Record<string, (data: FormData) => Promise<{ data?: { ok: boolean }; error?: any }>> = {
contact: actions.contact,
waitlist: actions.joinWaitlist,
upload: actions.upload,
}
for (const [id, action] of Object.entries(forms)) {
const form = document.getElementById(id) as HTMLFormElement | null
if (!form) continue
const status = form.querySelector('.status') as HTMLParagraphElement
form.addEventListener('submit', async (event) => {
event.preventDefault()
status.textContent = 'Submitting…'
const { data, error } = await action(new FormData(form))
if (error) {
// Field-level validation errors come back typed from the Zod schema.
const message = isInputError(error)
? Object.values(error.fields).flat()[0] ?? 'Please check the form and try again.'
: error.message
status.textContent = message
return
}
status.textContent = data?.ok ? 'Done ✅' : 'Submitted.'
form.reset()
// @ts-expect-error global injected by the Turnstile script
window.turnstile?.reset?.()
})
}
</script>

Die entscheidende Zeile ist await action(new FormData(form)). Es gibt keinen fetch-Aufruf und keinen Endpunkt zu schreiben. Sie rufen die Action mit den Formulardaten auf und erhalten ein typisiertes { data, error } zurück. isInputError unterscheidet Zod-Validierungsfehler (inline anzeigen) von geworfenen ActionErrors (die Nachricht anzeigen).

Lokal ausführen

Starten Sie den Entwicklungsserver:

Terminal window
npm run dev

Da die Bindings mit remote: true markiert sind, spricht die Entwicklungsumgebung mit Ihren echten D1- und R2-Ressourcen (stellen Sie sicher, dass Sie npx wrangler login ausgeführt haben). Öffnen Sie localhost:4321, senden Sie das Kontaktformular ab (der Turnstile-Test-Key wird automatisch akzeptiert), tragen Sie sich in die Warteliste ein und laden Sie eine Datei hoch.

Auf Cloudflare deployen

wrangler.jsonc verweist mit main bereits auf den Server-Einstiegspunkt des Adapters und mit assets auf ./dist, sodass das Deployment aus einem Build gefolgt von wrangler deploy besteht. Wenden Sie zuerst das Schema an und übertragen Sie Ihre Geheimnisse:

Terminal window
npx wrangler d1 execute forms --remote --file=./schema.sql
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put TURNSTILE_SECRET_KEY
npx wrangler secret put CONTACT_FROM_EMAIL
npm run build && npx wrangler deploy

Tauschen Sie die Turnstile-Test-Keys gegen Ihre echten Site- und Secret-Keys aus, richten Sie CONTACT_FROM_EMAIL auf eine verifizierte Resend-Domain und Ihre Formulare laufen am Edge.

Referenzen

Fazit

In dieser Anleitung haben Sie drei produktionsnahe Formulare in Astro erstellt (Kontakt, Warteliste und Datei-Upload) und dabei Actions für typsichere, serverseitig validierte Logik verwendet, mit Turnstile für Spam-Schutz, Cloudflare D1 und R2 für die Speicherung und Resend für E-Mails. Das Muster skaliert: Jedes Formular, das Sie später hinzufügen, ist nur ein weiterer Eintrag im server-Objekt, von Zod validiert und direkt vom Client aufrufbar, ohne separate API, die Sie erstellen oder pflegen müssen.

Wenn Sie lieber von einer sofort einsatzbereiten SaaS-Grundlage starten möchten, bei der Authentifizierung, Abrechnung, Speicherung und E-Mail bereits auf Astro und Cloudflare verdrahtet sind, werfen Sie einen Blick auf LaunchFast.

Wenn Sie Fragen oder Kommentare haben, können Sie mich gerne auf Twitter erreichen.

Weiterlesen