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

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

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

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

| Wert | Quelle | Vertrauen |
| --- | --- | --- |
| `rating`, `comment` | Das Formular | Was der Nutzer sagen will |
| `user_id`, `email` | `event.locals.user`, gesetzt vom `handle`-Hook | Auf dem Server aus dem Session-Cookie gelesen |
| `page` | Der `Referer` des Action-Requests | Nur Same-Origin; alles andere wird verworfen |
| `screen` | `page.route.id` in einem Hidden Input | Ein 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.

Diagramm: Wie ein SvelteKit-Feedback-Post von einem Layout-Formular durch eine Form Action läuft

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

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

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

`src/env.ts`:

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

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)

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

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

export {};
```

`src/hooks.server.ts`:

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

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

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

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

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

| Host | Adapter 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

| Problem | Was zu tun ist |
| --- | --- |
| Das Dankeschön erscheint auf `/feedback` statt auf der aktuellen Ansicht | Der 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 `null` | Der 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 abgelehnt | SvelteKit 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 `/feedback` | Eine Seite mit Actions kann nicht vorgerendert werden. Behalte `export const prerender = false` in ihrer `+page.server.ts`. |
| `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

### Kann ein SvelteKit-Formular eine Action auf einer anderen Route aufrufen?

Ja. Setze die action des Formulars auf die andere Route plus den Action-Namen, etwa /feedback?/send. Übergib mit use:enhance navigate: false an update, damit das Ergebnis auf der aktuellen Seite erscheint, statt dorthin zu navigieren.

### Wie bekomme ich den angemeldeten Nutzer in einer SvelteKit Form Action?

Lies die Session im handle-Hook und setze event.locals.user. Jede Action bekommt locals, kann also ID und E-Mail des Nutzers anhängen, ohne etwas zu vertrauen, das der Browser sendet.

### Warum macht meine Feedback-Route den Prerender-Build kaputt?

Seiten mit Form Actions können nicht vorgerendert werden, weil ein Server den POST beantworten muss. Setze prerender auf der Route, die die Action enthält, auf false.

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