Feedback-Formular für eine Website oder App7 Min. Lesezeit
Feedback-Formular in der App mit Astro bauen
Ein Feedback-Formular in einer Astro-App: eine Action, die den Nutzer aus den Middleware-Locals, die Seite aus dem Request und die Ansicht aus dem Routenmuster nimmt.
Schreib eine Astro Action mit accept: "form", die die Bewertung mit Zod validiert, den angemeldeten Nutzer aus context.locals liest und die Antwort mit einem Secret aus astro:env an dein Formular-Backend weiterleitet. Rendere das Formular mit dieser Action als action, damit es ohne JavaScript sendet, und füge ein kurzes Skript hinzu, das dieselbe Action an Ort und Stelle aufruft, wenn JavaScript verfügbar ist. Die Seite, um die es geht, kommt aus dem Referer des Requests, und Astro.routePattern benennt die Ansicht.
Der Astro-Tab der Hauptanleitung fragt jeden Besucher, wie hilfreich eine Seite war. Diese Seite setzt Feedback in eine Astro-App mit angemeldeten Nutzern, wo der Server entscheiden sollte, wer es geschrieben hat, und das Formular nur nach Bewertung und Kommentar fragt. Für Keys und Formular-Setup hat die Astro-Anleitung der Doku die Details.
Wer was ausfüllt
Astro Actions sind öffentliche Endpunkte, erreichbar unter einer URL, die nach der Action benannt ist. Jeder kann sendFeedback mit einem manipulierten Body aufrufen, also darf die Action einer übergebenen Nutzer-ID nicht glauben. Teile die Werte danach auf, wem man sie anvertrauen kann:
| Wert | Gesetzt von | Wie |
|---|---|---|
rating, comment | Dem Nutzer | Vom Zod-Schema der Action validiert |
user_id, email | Middleware | context.locals.user, bei jedem Request aus dem Session-Cookie |
page | Dem Request | Der Referer-Header, also die Seite, auf der das Formular steht |
screen | Dem Rendern der Seite | Astro.routePattern, etwa /app/projects/[id]/settings, in einem Hidden Input |
routePattern ist der Grund, warum Astro hier angenehm ist. Es benennt die Datei, die die Seite gerendert hat, sodass die Einstellungsansicht jedes Projekts unter einem Label landet, während page die echte Adresse behält. Es geht durch den Browser, also prüft das Schema seine Form und ersetzt alles Merkwürdige durch unknown.
- Ohne JavaScript sendet der Browser das Formular an die aktuelle Seite, mit der Action im Namen der URL.
- Die Middleware läuft bei jedem Request zuerst und legt den angemeldeten Nutzer auf context.locals.
- Die Action validiert Bewertung und Ansicht, ergänzt Nutzer und Seite und sendet mit dem Secret an Ikiform.
- Astro rendert die Seite erneut, und Astro.getActionResult() zeigt die Dankeszeile oder den Fehler.
- Mit JavaScript ruft das Skript dieselbe Action per fetch auf. Middleware und Handler laufen genau wie vorher.
- Das Skript ersetzt das Formular durch die Dankeszeile oder schreibt den Fehler darunter, ohne Neuladen.
Projekt-Setup
src/
actions/index.ts
components/FeedbackForm.astro
middleware.ts
env.d.ts
pages/app/projects/[id]/settings.astro
astro.config.mjs
.envDeklariere das Secret im Schema von astro:env. Astro startet dann nicht ohne es, typisiert den Import und hält es aus dem Client-Bundle heraus. Jeder Server-Adapter funktioniert; gezeigt wird Node.
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
adapter: node({ mode: "standalone" }),
env: {
schema: {
IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
},
},
});Leg für die lokale Arbeit IKIFORM_SECRET_KEY=ik_sk_... in .env ab. Erstelle den Key unter API keys mit dem Scope submissions:write. Füge dann diese Definition im Tab Fields des Formulars ein:
Vorschau
Führt den Validator von Ikiform in deinem Browser aus. Es wird nichts gesendet.
Bauen
Den Nutzer auf locals legen
Middleware läuft vor jeder Seite und Action. Lies dort einmal deine Session, mit dem, was dein Auth-Code bereitstellt, und jeder Handler bekommt das Ergebnis. readSession steht für diesen Code. Astros eigene Sessions funktionieren auch: Lies dann stattdessen context.session in der Action.
import { defineMiddleware } from "astro:middleware";
import { readSession } from "./lib/auth";
export const onRequest = defineMiddleware(async (context, next) => {
context.locals.user = await readSession(context.cookies.get("session")?.value);
return next();
});declare namespace App {
interface Locals {
user: { id: string; email: string } | null;
}
}Die Action
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
function pageFrom(referer: string | null) {
try {
const url = new URL(referer ?? "");
return url.origin + url.pathname;
} catch {
return "";
}
}
export const server = {
sendFeedback: defineAction({
accept: "form",
input: z.object({
rating: z.coerce.number().int().min(1, "Pick a score from 1 to 5.").max(5, "Pick a score from 1 to 5."),
comment: z.string().max(4000).nullish(),
screen: z.string().regex(/^\/[a-z0-9/[\]._-]{0,119}$/).catch("unknown"),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const user = context.locals.user;
if (!user) throw new ActionError({ code: "UNAUTHORIZED", message: "Your session ended. Sign in again, then send it." });
const origin = context.request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
rating: input.rating,
comment: input.comment ?? "",
page: pageFrom(context.request.headers.get("referer")),
screen: input.screen,
user_id: user.id,
email: user.email,
_gotcha: input._gotcha ?? "",
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return { sent: true };
throw new ActionError({
code: "BAD_GATEWAY",
message: result?.error?.message ?? "We couldn't send it. Try again in a moment.",
});
},
}),
};- Das zweite Argument des Handlers ist der Action-Kontext. Er hat
locals,requestundcookies, aber keinredirect, also wird eine fehlende Session zu einemUNAUTHORIZED-Fehler, den das Formular anzeigen kann. .catch("unknown")anscreenmacht aus einem gefälschten Labelunknown, statt die ganze Antwort scheitern zu lassen.- Leere Formular-Inputs erreichen eine
form-Action alsnull, deshalb sindcommentund der Honeypotnullish. - Sowohl der Formular-Post als auch der Aufruf des Skripts werden von der Seite gesendet, also enthält
Refererin beiden Fällen ihre Adresse.pageFrombehält Origin und Pfad. Originwird weitergereicht, damit lokale Tests alslocalhostzählen. Mehr dazu unten.
Die Formular-Komponente
---
import { actions, isInputError } from "astro:actions";
const result = Astro.getActionResult(actions.sendFeedback);
const scores = ["Broken", "Frustrating", "Fine", "Good", "Great"];
---
{result?.data?.sent ? (
<p role="status">Thanks. We read every one.</p>
) : (
<form method="POST" action={actions.sendFeedback} data-feedback>
<fieldset>
<legend>How is this screen working for you?</legend>
{scores.map((label, index) => (
<label><input type="radio" name="rating" value={index + 1} required /> {label}</label>
))}
</fieldset>
<label for="feedback-comment">What would make it better?</label>
<textarea id="feedback-comment" name="comment" rows="4" maxlength="4000"></textarea>
<input type="hidden" name="screen" value={Astro.routePattern} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p role="alert">
{result?.error && (isInputError(result.error) ? result.error.fields.rating?.[0] : result.error.message)}
</p>
<button>Send feedback</button>
</form>
)}
<script>
import { actions } from "astro:actions";
const form = document.querySelector<HTMLFormElement>("form[data-feedback]");
form?.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button")!;
const alert = form.querySelector("[role=alert]")!;
button.disabled = true;
button.textContent = "Sending";
const { error } = await actions.sendFeedback(new FormData(form));
if (!error) {
form.replaceWith(Object.assign(document.createElement("p"), { role: "status", textContent: "Thanks. We read every one." }));
return;
}
alert.textContent = error.message;
button.disabled = false;
button.textContent = "Send feedback";
});
</script>action={actions.sendFeedback}lässt das Formular ganz ohne JavaScript funktionieren. Astro verarbeitet den Post und rendert die Seite mit dem Ergebnis.isInputErrortrennt Schemafehler, diefieldshaben, von Fehlern, die der Handler geworfen hat, etwa der Session-Meldung.- Das Skript importiert
actionsausastro:actionsund ruft denselben Handler mit den Daten des Formulars auf. Astro bündelt es, also gibt es keine zusätzliche Datei. - Ein Absenden ohne JavaScript hinterlässt die Seite als POST-Ergebnis, und ein Neuladen fragt, ob erneut gesendet werden soll. Stört dich das, kannst du mit
getActionContextin der Middleware das Ergebnis speichern und weiterleiten.
Die Seite
---
import AppLayout from "../../../../layouts/AppLayout.astro";
import FeedbackForm from "../../../../components/FeedbackForm.astro";
export const prerender = false;
if (!Astro.locals.user) return Astro.redirect("/sign-in");
---
<AppLayout title="Project settings">
<h1>Project settings</h1>
<aside>
<FeedbackForm />
</aside>
</AppLayout>Rendering-Modus
Eine Form Action funktioniert nur auf einer Seite, die on demand gerendert wird. Vorgerenderte Seiten werden einmal gebaut, es gibt also keinen Request, an den gesendet werden könnte. Markiere mit der standardmäßigen statischen Ausgabe jede App-Seite mit export const prerender = false oder setze output: "server", wenn der Großteil der Site hinter einer Anmeldung liegt. Middleware läuft auch bei vorgerenderten Seiten zur Build-Zeit, wo es kein Cookie gibt, also ist locals.user dort null.
Honeypot, Menschlichkeitsprüfung und Limits
Der versteckte _gotcha-Input wird durchgereicht, und ein ausgefüllter wird als Spam gespeichert. Die Menschlichkeitsprüfung läuft bei einem Post mit Secret Key nie, und die Anmeldepflicht übernimmt diese Aufgabe. Astro prüft standardmäßig den Origin von Formular-Posts an On-Demand-Seiten, sodass eine andere Site das Formular nicht im Browser eines Nutzers absenden kann.
Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform Allowed domains und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das Rate-Limit Relaxed oder Custom, und halte IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.
Deployen
| Host | Adapter | Das Secret |
|---|---|---|
| Vercel | @astrojs/vercel | Füge IKIFORM_SECRET_KEY in den Umgebungsvariablen des Projekts hinzu. Nutzt du Astro-Sessions, konfiguriere einen Storage-Treiber; Vercel hat keinen Standard. |
| Netlify | @astrojs/netlify | Füge ihn in den Umgebungsvariablen der Site hinzu. Sessions nutzen standardmäßig Netlify Blobs. |
| Cloudflare | @astrojs/cloudflare | Führe npx wrangler secret put IKIFORM_SECRET_KEY aus und leg ihn lokal in .dev.vars ab. astro:env/server liest ihn, ebenso env aus cloudflare:workers. Sessions nutzen ein KV-Binding namens SESSION. |
Seit Astro 6 gibt es Astro.locals.runtime.env im Cloudflare-Adapter nicht mehr. Code, der den Key dort gelesen hat, braucht den astro:env-Import von oben.
Kostenlose lokale Tests
Ein Workspace ohne Tarif bekommt 50 kostenlose Testeinsendungen pro Monat von localhost, und Ikiform erkennt sie am Origin-Header. Der Browser sendet http://localhost:4321 an astro dev, aber das eigene fetch der Action an Ikiform sendet keinen Origin. Die Origin-Zeile reicht den des Browsers weiter, also sind lokale Tests kostenlos, und in Produktion ist der Header deine eigene Domain.
Prüfen, ob es funktioniert
- Starte
astro dev, melde dich an, öffne die Einstellungsseite eines Projekts und sende eine 2 mit Kommentar. - Prüf in Submissions, dass
screen/app/projects/[id]/settingsist,pagedie echte ID enthält unduser_idundemaildeine sind. - Schalte JavaScript aus und sende erneut. Die Seite lädt mit der Dankeszeile neu.
- Lösche das Session-Cookie und sende ab. Das Formular zeigt die Anmeldemeldung.
- Antworte auf die Benachrichtigungs-E-Mail. Sie ist an den angemeldeten Nutzer adressiert.
Stolperfallen
| Problem | Was zu tun ist |
|---|---|
| Das Formular sendet, aber die Seite zeigt kein Ergebnis | Die Seite ist vorgerendert. Füge export const prerender = false hinzu oder wechsle zu output: "server". |
IKIFORM_SECRET_KEY is missing beim Start des Builds | Secrets werden validiert, sobald astro:env/server importiert wird. Stell die Variable dem Build bereit, oder dort einen Platzhalter und zur Laufzeit den echten Wert. |
402 plan_required bei lokalen Tests | Der weitergereichte Origin ist leer oder nicht localhost. Öffne die App unter localhost:4321 und behalte die Origin-Zeile. |
locals.user ist in Produktion immer null | Name oder Domain des Cookies unterscheiden sich zwischen den Umgebungen, oder die Seite ist noch vorgerendert. Logge context.cookies.has("session") in der Middleware. |
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. |
Fragen
Setze ihn in src/middleware.ts auf context.locals und lies dann context.locals.user aus dem zweiten Argument des Handlers. Der andere Weg sind Astro-Sessions über context.session.
Ja, auf Seiten, die on demand gerendert werden. Setze die action des Formulars auf die Action und seine method auf POST und lies dann das Ergebnis mit Astro.getActionResult() in der Seite.
Rendere Astro.routePattern in einen Hidden Input. Es liefert das Muster der Routendatei, etwa /app/projects/[id]/settings, sodass Antworten nach Ansicht statt nach ID gruppiert werden.
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.