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

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/feedback-form/astro

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](https://ikiform.com/de/guides/feedback-form.md#form) 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](https://ikiform.com/de/docs/guides/astro.md) 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`.

Diagramm: Zwei Wege, auf denen ein Astro-Feedback-Formular dieselbe Action erreicht

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`:

```text
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`:

```js
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:

Formular: In-app feedback

- **Wie funktioniert diese Ansicht für dich?** (`rating`, rating, Pflichtfeld)
- **Was würde sie besser machen?** (`comment`, textarea)
- **Seite** (`page`, url)
- **Ansicht** (`screen`, text)
- **Nutzer** (`user_id`, text)
- **E-Mail** (`email`, email)

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`:

```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`:

```ts
declare namespace App {
  interface Locals {
    user: { id: string; email: string } | null;
  }
}
```

### Die Action

`src/actions/index.ts`:

```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`:

```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`:

```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](https://ikiform.com/de/docs/rate-limits.md) 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

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

| 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

### Wie bekomme ich den angemeldeten Nutzer in einer Astro Action?

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.

### Funktionieren Astro Actions ohne JavaScript?

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.

### Woher weiß ich in Astro, von welcher Ansicht Feedback kam?

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.

### Ist Ikiform kostenlos?

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.

## Weitere Anleitungen

- [Feedback-Formular für eine Website oder App](https://ikiform.com/de/guides/feedback-form.md): Setze ein Feedback-Formular auf jede Seite oder in deine App: eine Bewertung, ein Kommentar, die Herkunftsseite, Spamfilter und Alerts bei niedrigen Werten.
- [Feedback-Formular mit SvelteKit](https://ikiform.com/de/guides/feedback-form/sveltekit.md): Ein Feedback-Formular in einer SvelteKit-App: Ein Layout-Formular sendet an eine Form Action, die auf dem Server den Nutzer aus locals und die Seite aus dem Request ergänzt.
- [Feedback-Formular mit Nuxt](https://ikiform.com/de/guides/feedback-form/nuxt.md): Ein Feedback-Formular in einer Nuxt-App: eine Server-Route, die die Nutzer-Session liest, die Seite aus dem Referer nimmt und auch ohne geladenes JavaScript funktioniert.
- [Feedback-Formular mit Next.js](https://ikiform.com/de/guides/feedback-form/nextjs.md): Ein Feedback-Formular in einer Next.js-App: eine Server Action, die die Session liest, die Seite aus dem Request nimmt und eine Bewertung mit dem Nutzer weiterleitet.
- [Formular für Bug-Reports](https://ikiform.com/de/guides/feedback-form/bug-report.md): Ein Bug-Report-Formular, das nach Schritten und Schweregrad fragt und URL, Browser, Viewport und letzte Fehler von der Seite selbst ergänzen lässt.
- [Formular für Feature-Wünsche](https://ikiform.com/de/guides/feedback-form/feature-request.md): Ein Formular für Feature-Wünsche, das nach dem Problem statt nach der Lösung fragt, Wünsche nach Thema speichert, Personen statt Zeilen zählt und sie beim Release anschreibt.
