Feedback-Formular für eine Website oder App

Feedback-Formular für eine Website oder App8 Min. Lesezeit

Feedback-Formular in der App mit SvelteKit bauen

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.

Preet SutharGründer, Ikiform

Setze das Formular in das Layout, das deine Ansichten für angemeldete Nutzer umschließt, und sende es mit use:enhance an eine einzige benannte Action, /feedback?/send. Ein handle-Hook legt den Nutzer auf event.locals, also ergänzt die Action ID und E-Mail selbst und liest die Seite aus dem Request. Der Browser sendet nur Bewertung, Kommentar und Routen-ID.

Der SvelteKit-Tab der Hauptanleitung sendet JSON von jeder Seite an einen +server.ts-Endpunkt, angemeldet oder nicht. Diese Seite baut die Version für das Innere einer App: eine echte Form Action, die funktioniert, bevor JavaScript lädt, mit der Identität des Nutzers aus der Session auf dem Server. Sie ist für SvelteKit 3 und Svelte 5 geschrieben. Keys und erlaubte Domains behandelt die SvelteKit-Anleitung der Doku.

Warum eine Action für jede Ansicht

Form Actions gehören zu einer Seite, aber ein Feedback-Formular lebt in einem Layout und erscheint auf Dutzenden Seiten. Eine Action in jede +page.server.ts zu kopieren, wäre ein Chaos. SvelteKit lässt ein Formular mit einem Query-String die Action einer anderen Route aufrufen, also sendet jede Ansicht an /feedback?/send, und eine Datei enthält die Logik. Jeder Wert kommt dann von der richtigen Stelle:

WertQuelleVertrauen
rating, commentDas FormularWas der Nutzer sagen will
user_id, emailevent.locals.user, gesetzt vom handle-HookAuf dem Server aus dem Session-Cookie gelesen
pageDer Referer des Action-RequestsNur Same-Origin; alles andere wird verworfen
screenpage.route.id in einem Hidden InputEin Label zum Gruppieren. Wird vor dem Speichern gegen ein Pattern geprüft

page.route.id ist der Dateipfad der Route, etwa /(app)/projects/[id]/settings. Alle Projekt-Einstellungsseiten teilen ihn, und genau das willst du, wenn du Bewertungen pro Ansicht mittelst. Die Action entfernt Gruppensegmente wie (app), da sie ein Detail deiner Ordnerstruktur sind.

Wie ein SvelteKit-Feedback-Post von einem Layout-Formular durch eine Form Action läuftLayout-FormularjedeAnsicht,use:enhancehandle-HookSessioninlocals.userAction?/sendprüftNutzerundBewertungIkiformSecretKey,speichertsiepage.formDankeaufderselbenAnsichtfail(401oder400)MeldungunterdemFormularPOST /feedbacklocals+ Nutzer, Seitenavigate: falsekein Nutzer oder ungültige BewertungWie ein SvelteKit-Feedback-Post von einem Layout-Formular durch eine Form Action läuftLayout-Formularjede Ansicht,use:enhancehandle-HookSession in locals.userAction?/sendprüft Nutzerund BewertungIkiformSecret Key, speichertsiepage.formDanke auf derselbenAnsichtfail(401oder 400)Meldungunter demFormularPOST /feedbacklocals+ Nutzer, Seitenavigate: falsekein Nutzer oder ungültige Bewertung
  1. Der Nutzer bewertet die Ansicht im Formular des Layouts. use:enhance sendet es ohne Seitenladen an /feedback?/send.
  2. Der handle-Hook läuft zuerst, liest das Session-Cookie und legt den Nutzer auf event.locals.
  3. Die send-Action prüft, ob es einen Nutzer und eine ganze Bewertung von 1 bis 5 gibt. Wenn nicht, gibt sie fail mit einer Meldung zurück.
  4. Sie ergänzt ID und E-Mail des Nutzers, die Seite aus dem Referer und die bereinigte Routen-ID und sendet mit dem Secret Key.
  5. update mit navigate auf false wendet das Ergebnis auf die aktuelle Ansicht an, sodass die Dankeszeile dort erscheint, wo der Nutzer ist.
Ohne JavaScript landet der Browser stattdessen auf /feedback, das dasselbe Ergebnis und einen Link zurück zeigt.

Projekt-Setup

Projektstruktur
src/
  app.d.ts
  env.ts
  hooks.server.ts
  lib/
    FeedbackForm.svelte
    server/session.ts
  routes/
    (app)/+layout.svelte
    feedback/
      +page.server.ts
      +page.svelte
.env

Erstelle unter API keys einen Secret Key mit dem Scope submissions:write. SvelteKit 3 deklariert Umgebungsvariablen in src/env.ts. Variablen sind privat und werden zur Laufzeit gelesen, sofern du nichts anderes angibst, also erreicht der Key nie ein Browser-Bundle und kann sich bei deinem Host ohne Rebuild ändern. .env befüllt ihn während der Entwicklung.

.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
src/env.ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});

Füge diese Definition im Tab Fields des Formulars ein. Das Formular zeigt Bewertung und Kommentar; die Action liefert den Rest, und das Feld email sorgt dafür, dass Reply in jeder Benachrichtigung an den Nutzer geht.

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.

Sende eine Bewertung von 6 oder ein Feld, das die Definition nicht listet, und die Vorschau zeigt den 422, den die Action in eine Meldung verwandelt.

Bauen

Den Nutzer auf locals legen

handle läuft bei jedem Request, den der Server erhält, auch bei Posts an Form Actions. Typisiere den Nutzer einmal in app.d.ts und lies ihn dann im Hook aus deiner Session. readSession steht für das, was dein Auth-Code schon mit dem Cookie macht.

src/app.d.ts
declare global {
  namespace App {
    interface Locals {
      user: { id: string; email: string } | null;
    }
  }
}

export {};
src/hooks.server.ts
import type { Handle } from "@sveltejs/kit/hooks";
import { readSession } from "#lib/server/session.js";

export const handle: Handle = async ({ event, resolve }) => {
  event.locals.user = await readSession(event.cookies.get("session"));
  return resolve(event);
};

Die Form-Action

src/routes/feedback/+page.server.ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";

export const prerender = false;

const SCREEN = /^[a-z0-9/[\]._-]{1,120}$/;

function pageFrom(referer: string | null, origin: string) {
  try {
    const url = new URL(referer ?? "");
    return url.origin === origin ? url.origin + url.pathname : "";
  } catch {
    return "";
  }
}

export const actions = {
  send: async ({ request, locals, url }) => {
    const data = await request.formData();
    if (!locals.user) return fail(401, { message: "Your session ended. Sign in again, then send it." });

    const rating = Number(data.get("rating"));
    if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
      return fail(400, { message: "Pick a score from 1 to 5." });
    }

    const screen = String(data.get("screen") ?? "").replace(/\/\([^)]+\)/g, "") || "/";
    const back = pageFrom(request.headers.get("referer"), url.origin);

    let response: Response;
    try {
      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: request.headers.get("origin") ?? url.origin,
        },
        body: JSON.stringify({
          rating,
          comment: String(data.get("comment") ?? "").trim().slice(0, 4000),
          page: back,
          screen: SCREEN.test(screen) ? screen : "unknown",
          user_id: locals.user.id,
          email: locals.user.email,
          _gotcha: String(data.get("_gotcha") ?? ""),
        }),
        signal: AbortSignal.timeout(8000),
      });
    } catch {
      return fail(502, { message: "We couldn't send it. Try again in a moment." });
    }

    if (response.status === 429) return fail(429, { message: "Too many answers at once. Try again in a minute." });
    const result = await response.json().catch(() => null);
    if (!result?.ok) {
      return fail(400, { message: result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "We couldn't send it." });
    }
    return { sent: true, back };
  },
} satisfies Actions;
  • fail gibt einen Status und Daten zurück, ohne eine Exception zu werfen. In SvelteKit 3 bekommt auch ein enhanced Formular diesen Status, sodass 401 und 400 im Netzwerk-Tab als das erscheinen, was sie sind.
  • pageFrom behält einen Referer nur, wenn er von deinem eigenen Origin stammt, und entfernt den Query-String.
  • export const prerender = false steht absichtlich dort. Eine Seite mit Actions kann nicht vorgerendert werden, also scheitert der Build ohne diese Zeile, wenn dein Root-Layout standardmäßig vorrendert.
  • Die Origin-Zeile reicht den Origin des Browsers weiter, mit dem der App als Fallback, und sagt Ikiform, woher der Request kam. Das lässt lokale Tests als localhost zählen, siehe unten.
  • Ein 429 von Ikiform bekommt eine eigene Meldung, denn hinter einem Server bedeutet er, dass das Formular als Ganzes ausgelastet ist, nicht dieser Nutzer.

Das Layout-Formular

src/lib/FeedbackForm.svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { page } from "$app/state";

  const scores = ["Broken", "Frustrating", "Fine", "Good", "Great"];
  let sending = $state(false);
</script>

{#if page.form?.sent}
  <p role="status">Thanks. We read every one.</p>
{:else}
  <form
    method="POST"
    action="/feedback?/send"
    aria-busy={sending}
    use:enhance={() => {
      sending = true;
      return async ({ update }) => {
        await update({ navigate: false, refreshAll: false });
        sending = false;
      };
    }}
  >
    <fieldset>
      <legend>How is this screen working for you?</legend>
      {#each scores as label, index}
        <label><input type="radio" name="rating" value={index + 1} required /> {label}</label>
      {/each}
    </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={page.route.id ?? ""} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if page.form?.message}
      <p role="alert">{page.form.message}</p>
    {/if}
    <button disabled={sending}>{sending ? "Sending" : "Send feedback"}</button>
  </form>
{/if}
  • Standardmäßig navigiert ein enhanced Formular, dessen Action auf einer anderen Seite liegt, nach dem Post dorthin. navigate: false hält den Nutzer auf seiner Ansicht und legt das Ergebnis hier in page.form.
  • refreshAll: false überspringt nach einem Erfolg das erneute Ausführen jeder load-Funktion. Feedback ändert nichts von dem, was die Seite zeigt.
  • Bei einem Fehler wird das Formular nicht zurückgesetzt, also bleibt der Kommentar neben der Meldung im Feld.
  • page.form gehört zur aktuellen Seite. Navigiert der Nutzer, wird es geleert, und die nächste Ansicht zeigt ein frisches Formular.
src/routes/(app)/+layout.svelte
<script lang="ts">
  import FeedbackForm from "#lib/FeedbackForm.svelte";

  let { children } = $props();
</script>

<main>{@render children()}</main>
<aside>
  <FeedbackForm />
</aside>

Ohne JavaScript funktioniert dasselbe Formular weiterhin. Der Browser sendet an /feedback?/send und landet auf /feedback, das das Ergebnis und einen Link zurück zur Herkunftsseite rendert:

src/routes/feedback/+page.svelte
<script lang="ts">
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
</script>

{#if form?.sent}
  <p role="status">Thanks. We read every one.</p>
  <a href={form.back || "/"}>Back to where you were</a>
{:else if form?.message}
  <p role="alert">{form.message}</p>
  <a href="/">Back to the app</a>
{:else}
  <p>Send feedback from the panel on any screen of the app.</p>
{/if}

Honeypot, Menschlichkeitsprüfung und Limits

Der versteckte _gotcha-Input geht unverändert durch, und Ikiform speichert einen ausgefüllten als Spam. Die Menschlichkeitsprüfung von Ikiform wird bei Posts mit Secret Key übersprungen. Hier lehnt die Action bereits jeden ohne Session ab, was die Bots fernhält, die eine Menschlichkeitsprüfung stoppen würde. Bleibt ein angemeldetes Konto, das in einer Schleife sendet, also setze ein Rate-Limit-Preset für ein Formular, das von einem Server aus sendet.

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.

Rendering und Deployment

Die Ansichten um das Formular können beliebig serverseitig gerendert oder vorgerendert werden; das Formular selbst braucht beim Rendern nichts. Nur /feedback muss auf einem Server laufen. Adapter werden in vite.config.ts im Plugin sveltekit() gesetzt.

HostAdapter und Key
Vercel@sveltejs/adapter-vercel. Füge IKIFORM_SECRET_KEY unter Environment Variables hinzu. Die Edge-Runtime gibt es in SvelteKit 3 nicht mehr, also läuft die Action in einer Node-Funktion.
Netlify@sveltejs/adapter-netlify. Setze den Key in den Umgebungsvariablen der Site.
Cloudflare@sveltejs/adapter-cloudflare mit Wrangler 4.118 oder neuer als Peer-Dependency. Speichere den Key als Worker-Secret, etwa mit wrangler secret put IKIFORM_SECRET_KEY, und lies ihn wie oben über $app/env/private.

Kostenlos auf localhost testen

Ohne Tarif empfängt ein Workspace 50 Testeinsendungen pro Monat von localhost. Ikiform entscheidet das anhand des Origin-Headers, und ein fetch von deinem Server sendet keinen, außer du setzt ihn. Deshalb reicht die Action den Origin des Requests weiter: Unter vite dev ist das http://localhost:5173, und der Post zählt als kostenloser Test. Lässt du ihn weg, kommen lokale Tests mit 402 plan_required zurück und werden zurückgehalten, bis du einen Tarif wählst.

Prüfen, ob es funktioniert

  1. Starte vite dev, melde dich an, öffne die Einstellungsansicht eines Projekts und sende eine Bewertung von 2 mit Kommentar.
  2. Öffne Submissions. screen lautet /projects/[id]/settings, page enthält die echte Projekt-ID, und user_id und email passen zu deinem Konto.
  3. Lösche das Session-Cookie und sende erneut. Das Formular zeigt die Anmeldemeldung und behält deinen Kommentar.
  4. Schalte JavaScript aus und sende ab. Du landest auf /feedback mit der Dankeszeile und einem Link zurück zur Ansicht.
  5. Antworte auf die Benachrichtigungs-E-Mail. Sie geht an den angemeldeten Nutzer.

Stolperfallen

ProblemWas zu tun ist
Das Dankeschön erscheint auf /feedback statt auf der aktuellen AnsichtDer enhance-Callback ruft update() ohne navigate: false auf, oder das Formular ist nicht enhanced. Prüf den use:enhance-Block.
locals.user ist in der Action immer nullDer Hook liest einen anderen Cookie-Namen, oder der path des Cookies deckt /feedback nicht ab. SvelteKit 3 setzt neue Cookies standardmäßig auf /, aber von älterem Code gesetzte Cookies können auf einen Unterpfad beschränkt sein.
Ein Post von einer anderen Domain, die dir gehört, wird abgelehntSvelteKit prüft immer den Origin von Formular-Posts. Füge diesen Origin zu csrf.trustedOrigins hinzu, statt ihn für alle zu öffnen.
Der Build scheitert mit einem Fehler zum Prerendering von /feedbackEine Seite mit Actions kann nicht vorgerendert werden. Behalte export const prerender = false in ihrer +page.server.ts.
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.