Feedback-Formular für eine Website oder App

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.

Preet SutharGründer, Ikiform

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:

WertGesetzt vonWie
rating, commentDem NutzerVom Zod-Schema der Action validiert
user_id, emailMiddlewarecontext.locals.user, bei jedem Request aus dem Session-Cookie
pageDem RequestDer Referer-Header, also die Seite, auf der das Formular steht
screenDem Rendern der SeiteAstro.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.

Zwei Wege, auf denen ein Astro-Feedback-Formular dieselbe Action erreichtohne JavaScriptmit dem SkriptFormular-POSTan dieaktuelleSeiteMiddlewarelocals.useraus demCookiesendFeedbackZod, dannfetchSeiterendertneugetActionResult()actions.sendFeedback()FormDataaus demFormularMiddlewaregleichesCookie,gleicherNutzersendFeedbackgleicherHandlerFormularersetzt{ data }oder {error }Seiten-URLErgebnisfetchJSONZwei Wege, auf denen ein Astro-Feedback-Formular dieselbe Action erreichtohne JavaScriptmit dem SkriptFormular-POSTan die aktuelle SeiteMiddlewarelocals.user aus dem CookiesendFeedbackZod, dann fetchSeite rendert neugetActionResult()actions.sendFeedback()FormData aus dem FormularMiddlewaregleiches Cookie, gleicherNutzersendFeedbackgleicher HandlerFormular ersetzt{ data } oder { error }Seiten-URLErgebnisfetchJSON
  1. Ohne JavaScript sendet der Browser das Formular an die aktuelle Seite, mit der Action im Namen der URL.
  2. Die Middleware läuft bei jedem Request zuerst und legt den angemeldeten Nutzer auf context.locals.
  3. Die Action validiert Bewertung und Ansicht, ergänzt Nutzer und Seite und sendet mit dem Secret an Ikiform.
  4. Astro rendert die Seite erneut, und Astro.getActionResult() zeigt die Dankeszeile oder den Fehler.
  5. Mit JavaScript ruft das Skript dieselbe Action per fetch auf. Middleware und Handler laufen genau wie vorher.
  6. Das Skript ersetzt das Formular durch die Dankeszeile oder schreibt den Fehler darunter, ohne Neuladen.
Ein Handler, zwei Transportwege. Beide Requests kommen von der Seite, also tragen beide das Session-Cookie und die Seite im Referer.

Projekt-Setup

Projektstruktur
src/
  actions/index.ts
  components/FeedbackForm.astro
  middleware.ts
  env.d.ts
  pages/app/projects/[id]/settings.astro
astro.config.mjs
.env

Deklariere 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.

astro.config.mjs
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.

Wie funktioniert diese Ansicht für dich?

Von deinem Server aus dem Request gesetzt.

Das Schema in der Action spiegelt diese Limits, sodass die meisten fehlerhaften Eingaben abgelehnt werden, bevor sie einen Request kosten.

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.

src/middleware.ts
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();
});
src/env.d.ts
declare namespace App {
  interface Locals {
    user: { id: string; email: string } | null;
  }
}

Die Action

src/actions/index.ts
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, request und cookies, aber kein redirect, also wird eine fehlende Session zu einem UNAUTHORIZED-Fehler, den das Formular anzeigen kann.
  • .catch("unknown") an screen macht aus einem gefälschten Label unknown, statt die ganze Antwort scheitern zu lassen.
  • Leere Formular-Inputs erreichen eine form-Action als null, deshalb sind comment und der Honeypot nullish.
  • Sowohl der Formular-Post als auch der Aufruf des Skripts werden von der Seite gesendet, also enthält Referer in beiden Fällen ihre Adresse. pageFrom behält Origin und Pfad.
  • Origin wird weitergereicht, damit lokale Tests als localhost zählen. Mehr dazu unten.

Die Formular-Komponente

src/components/FeedbackForm.astro
---
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.
  • isInputError trennt Schemafehler, die fields haben, von Fehlern, die der Handler geworfen hat, etwa der Session-Meldung.
  • Das Skript importiert actions aus astro:actions und 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 getActionContext in der Middleware das Ergebnis speichern und weiterleiten.

Die Seite

src/pages/app/projects/[id]/settings.astro
---
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

HostAdapterDas Secret
Vercel@astrojs/vercelFüge IKIFORM_SECRET_KEY in den Umgebungsvariablen des Projekts hinzu. Nutzt du Astro-Sessions, konfiguriere einen Storage-Treiber; Vercel hat keinen Standard.
Netlify@astrojs/netlifyFüge ihn in den Umgebungsvariablen der Site hinzu. Sessions nutzen standardmäßig Netlify Blobs.
Cloudflare@astrojs/cloudflareFü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

  1. Starte astro dev, melde dich an, öffne die Einstellungsseite eines Projekts und sende eine 2 mit Kommentar.
  2. Prüf in Submissions, dass screen /app/projects/[id]/settings ist, page die echte ID enthält und user_id und email deine sind.
  3. Schalte JavaScript aus und sende erneut. Die Seite lädt mit der Dankeszeile neu.
  4. Lösche das Session-Cookie und sende ab. Das Formular zeigt die Anmeldemeldung.
  5. Antworte auf die Benachrichtigungs-E-Mail. Sie ist an den angemeldeten Nutzer adressiert.

Stolperfallen

ProblemWas zu tun ist
Das Formular sendet, aber die Seite zeigt kein ErgebnisDie Seite ist vorgerendert. Füge export const prerender = false hinzu oder wechsle zu output: "server".
IKIFORM_SECRET_KEY is missing beim Start des BuildsSecrets 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 TestsDer weitergereichte Origin ist leer oder nicht localhost. Öffne die App unter localhost:4321 und behalte die Origin-Zeile.
locals.user ist in Produktion immer nullName 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_failedDer 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

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.