# Créer un formulaire de feedback dans l'application avec SvelteKit

> Un formulaire de feedback dans une application SvelteKit : un formulaire de layout envoie à une seule form action, qui ajoute côté serveur l'utilisateur depuis locals et la page depuis la requête.

Publié le 2026-10-25 · https://ikiform.com/fr/guides/feedback-form/sveltekit

Placez le formulaire dans le layout qui enveloppe vos écrans connectés et envoyez-le à une seule action nommée, `/feedback?/send`, avec `use:enhance`. Un hook `handle` place l'utilisateur dans `event.locals` : l'action ajoute donc elle-même son id et son e-mail, et lit la page dans la requête. Le navigateur n'envoie que la note, le commentaire et l'id de la route.

L'[onglet SvelteKit du guide principal](https://ikiform.com/fr/guides/feedback-form.md#form) envoie du JSON à un endpoint `+server.ts` depuis n'importe quelle page, connecté ou non. Cette page construit la version pour l'intérieur d'une application : une vraie form action qui fonctionne avant le chargement de JavaScript, avec l'identité de l'utilisateur prise dans la session côté serveur. Elle est écrite pour SvelteKit 3 et Svelte 5. Les clés et les domaines autorisés sont traités dans le [guide SvelteKit de la documentation](https://ikiform.com/fr/docs/guides/sveltekit.md).

## Pourquoi une seule action pour tous les écrans

Les form actions appartiennent à une page, mais un formulaire de feedback vit dans un layout et s'affiche sur des dizaines de pages. Copier une action dans chaque `+page.server.ts` serait un désordre. SvelteKit permet à un formulaire d'appeler l'action d'une autre route avec un paramètre d'URL : chaque écran envoie donc à `/feedback?/send`, et un seul fichier contient la logique. Chaque valeur vient alors du bon endroit :

| Valeur | Source | Confiance |
| --- | --- | --- |
| `rating`, `comment` | Le formulaire | Ce que l'utilisateur veut dire |
| `user_id`, `email` | `event.locals.user`, renseigné par le hook `handle` | Lu dans le cookie de session, côté serveur |
| `page` | Le `Referer` de la requête de l'action | Même origine uniquement ; tout le reste est écarté |
| `screen` | `page.route.id` dans un input masqué | Une étiquette de regroupement. Vérifiée par un motif avant d'être stockée |

`page.route.id` est le chemin du fichier de la route, comme `/(app)/projects/[id]/settings`. Toutes les pages de réglages de projet le partagent, ce qui est exactement ce qu'il faut pour faire la moyenne des notes par écran. L'action retire les segments de groupe comme `(app)`, qui ne sont qu'un détail de l'organisation de vos dossiers.

Schéma : Comment un envoi de feedback SvelteKit passe d'un formulaire de layout à une seule form action

1. L'utilisateur note l'écran dans le formulaire du layout. use:enhance l'envoie à /feedback?/send sans chargement de page.
2. Le hook handle s'exécute en premier, lit le cookie de session et place l'utilisateur dans event.locals.
3. L'action send vérifie qu'il y a un utilisateur et une note entière de 1 à 5. Sinon, elle renvoie fail avec un message.
4. Elle ajoute l'id et l'e-mail de l'utilisateur, la page issue de Referer et l'id de route nettoyé, puis envoie avec la clé secrète.
5. update avec navigate à false applique le résultat à l'écran courant : la ligne de remerciement s'affiche là où se trouve l'utilisateur.

Sans JavaScript, le navigateur arrive plutôt sur /feedback, qui affiche le même résultat et un lien de retour.

## Mise en place du projet

`Arborescence du projet`:

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

Créez une clé secrète avec le scope `submissions:write` sous **API keys**. SvelteKit 3 déclare les variables d'environnement dans `src/env.ts`. Les variables sont privées et lues à l'exécution sauf indication contraire : la clé n'atteint donc jamais un bundle navigateur et peut changer chez votre hébergeur sans rebuild. `.env` la fournit pendant le développement.

`.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: {},
});
```

Collez cette définition dans l'onglet **Fields** du formulaire. Le formulaire affiche la note et le commentaire ; l'action fournit le reste, et le champ `email` fait que **Reply** sur chaque notification écrit à l'utilisateur.

Formulaire : In-app feedback

- **Comment cet écran fonctionne-t-il pour vous ?** (`rating`, rating, obligatoire)
- **Qu'est-ce qui l'améliorerait ?** (`comment`, textarea)
- **Page** (`page`, url)
- **Écran** (`screen`, text)
- **Utilisateur** (`user_id`, text)
- **E-mail** (`email`, email)

Envoyez une note de `6` ou un champ absent de la définition : l'aperçu affiche le `422` que l'action transforme en message.

## La construire

### Placer l'utilisateur dans locals

`handle` s'exécute pour chaque requête reçue par le serveur, y compris les envois vers les form actions. Typez l'utilisateur une fois dans `app.d.ts`, puis lisez-le depuis votre session dans le hook. `readSession` représente ce que votre code d'authentification fait déjà avec le cookie.

`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);
};
```

### La 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` renvoie un statut et des données sans lever d'exception. Dans SvelteKit 3, un formulaire amélioré reçoit aussi ce statut : `401` et `400` apparaissent donc tels quels dans l'onglet réseau.
- `pageFrom` ne garde un `Referer` que s'il vient de votre propre origine, et supprime les paramètres d'URL.
- `export const prerender = false` est là exprès. Une page avec des actions ne peut pas être prérendue : si votre layout racine prérend par défaut, le build échoue sans cette ligne.
- La ligne `Origin` transmet l'origine du navigateur, ou à défaut celle de l'application, et indique à Ikiform d'où vient la requête. C'est ce qui fait compter les tests locaux comme `localhost`, voir plus bas.
- Un `429` d'Ikiform a son propre message, car derrière un serveur il signifie que le formulaire dans son ensemble est saturé, pas cet utilisateur.

### Le formulaire du layout

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

- Par défaut, un formulaire amélioré dont l'action se trouve sur une autre page navigue vers celle-ci après l'envoi. `navigate: false` garde l'utilisateur sur son écran et place le résultat ici, dans `page.form`.
- `refreshAll: false` évite de relancer toutes les fonctions `load` après un succès. Le feedback ne change rien à ce qu'affiche la page.
- En cas d'échec, le formulaire n'est pas réinitialisé : le commentaire reste dans le champ, à côté du message.
- `page.form` appartient à la page courante. Quand l'utilisateur navigue, il se vide, et l'écran suivant affiche un formulaire vierge.

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

Sans JavaScript, le même formulaire fonctionne toujours. Le navigateur envoie à `/feedback?/send` et arrive sur `/feedback`, qui affiche le résultat et un lien vers la page d'origine :

`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, vérification humaine et limites

L'input masqué `_gotcha` passe sans modification, et Ikiform stocke un honeypot rempli comme spam. La vérification humaine d'Ikiform est ignorée pour les envois avec clé secrète. Ici, l'action refuse déjà toute personne sans session, ce qui écarte les bots qu'une vérification humaine arrêterait. Il reste le cas d'un compte connecté qui envoie en boucle : choisissez donc un préréglage de limite de débit adapté à un formulaire qui envoie depuis un seul serveur.

Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore **Allowed domains** et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la [limite de débit](https://ikiform.com/fr/docs/rate-limits.md) Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez `IKIFORM_SECRET_KEY` dans des variables d'environnement réservées au serveur.

## Rendu et déploiement

Les écrans autour du formulaire peuvent être rendus côté serveur ou prérendus, comme vous voulez ; le formulaire lui-même n'a besoin de rien au rendu. Seul `/feedback` doit tourner sur un serveur. Les adaptateurs se règlent dans `vite.config.ts`, dans le plugin `sveltekit()`.

| Hébergeur | Adaptateur et clé |
| --- | --- |
| Vercel | `@sveltejs/adapter-vercel`. Ajoutez `IKIFORM_SECRET_KEY` sous **Environment Variables**. Le runtime edge a disparu dans SvelteKit 3 : l'action s'exécute donc dans une fonction Node. |
| Netlify | `@sveltejs/adapter-netlify`. Définissez la clé dans les variables d'environnement du site. |
| Cloudflare | `@sveltejs/adapter-cloudflare` avec Wrangler 4.118 ou plus récent, sa dépendance peer. Stockez la clé comme secret du Worker, par exemple avec `wrangler secret put IKIFORM_SECRET_KEY`, et lisez-la via `$app/env/private` comme ci-dessus. |

## Tester gratuitement sur localhost

Sans offre, un espace de travail reçoit 50 soumissions de test par mois depuis `localhost`. Ikiform en décide d'après l'en-tête `Origin`, et un `fetch` depuis votre serveur n'en envoie aucun sauf si vous le définissez. C'est pourquoi l'action transmet l'`Origin` de la requête : sous `vite dev`, c'est `http://localhost:5173`, et l'envoi compte comme un test gratuit. Sans cela, les tests locaux reviennent en `402 plan_required`, retenus jusqu'à ce que vous choisissiez une offre.

## Vérifiez que tout fonctionne

1. Lancez `vite dev`, connectez-vous, ouvrez l'écran de réglages d'un projet et envoyez une note de 2 avec un commentaire.
2. Ouvrez **Submissions**. `screen` vaut `/projects/[id]/settings`, `page` contient le vrai id du projet, et `user_id` et `email` correspondent à votre compte.
3. Effacez le cookie de session et envoyez à nouveau. Le formulaire affiche le message de connexion et garde votre commentaire.
4. Désactivez JavaScript et envoyez. Vous arrivez sur `/feedback` avec la ligne de remerciement et un lien vers l'écran.
5. Répondez à l'e-mail de notification. La réponse part à l'utilisateur connecté.

## Pièges

| Problème | Que faire |
| --- | --- |
| Le remerciement s'affiche sur `/feedback` au lieu de l'écran courant | Le callback d'enhance appelle `update()` sans `navigate: false`, ou le formulaire n'est pas amélioré. Vérifiez le bloc `use:enhance`. |
| `locals.user` vaut toujours `null` dans l'action | Le hook lit un autre nom de cookie, ou le `path` du cookie ne couvre pas `/feedback`. SvelteKit 3 donne par défaut `/` aux nouveaux cookies, mais ceux créés par du code plus ancien peuvent être limités à un sous-chemin. |
| Un envoi depuis un autre de vos domaines est refusé | SvelteKit vérifie toujours l'origine des envois de formulaire. Ajoutez cette origine à `csrf.trustedOrigins` plutôt que d'ouvrir à tout le monde. |
| Le build échoue avec une erreur sur le prérendu de `/feedback` | Une page avec des actions ne peut pas être prérendue. Gardez `export const prerender = false` dans son `+page.server.ts`. |
| `422 validation_failed` | L'envoi ne correspond pas à la définition des champs du formulaire. `error.fields` nomme chaque champ et la raison. Faites correspondre le `name` de l'input à l'id du champ. |

## Questions

### Un formulaire SvelteKit peut-il appeler une action d'une autre route ?

Oui. Réglez l'action du formulaire sur l'autre route suivie du nom de l'action, comme /feedback?/send. Avec use:enhance, passez navigate: false à update pour que le résultat s'affiche sur la page courante au lieu d'y naviguer.

### Comment obtenir l'utilisateur connecté dans une form action SvelteKit ?

Lisez la session dans le hook handle et renseignez event.locals.user. Chaque action reçoit locals : elle peut donc joindre l'id et l'e-mail de l'utilisateur sans se fier à ce qu'envoie le navigateur.

### Pourquoi ma route de feedback casse-t-elle le build prérendu ?

Les pages avec des form actions ne peuvent pas être prérendues, car un serveur doit répondre au POST. Réglez prerender sur false pour la route qui contient l'action.

### Ikiform est-il gratuit ?

Non, il n'y a pas d'offre gratuite. Les tests sont gratuits : vous pouvez envoyer 50 soumissions de test par mois depuis localhost avant de payer. Pour passer en production, Pro commence à 6 $/mois pour 500 soumissions, ou vous pouvez payer une seule fois 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais.

## Autres guides

- [Formulaire de feedback pour un site ou une application](https://ikiform.com/fr/guides/feedback-form.md): Placez un formulaire de feedback sur n'importe quelle page ou dans votre application : une note, un commentaire, la page d'origine, le filtrage du spam et des alertes quand une note est basse.
- [Formulaire de feedback Nuxt](https://ikiform.com/fr/guides/feedback-form/nuxt.md): Un formulaire de feedback dans une application Nuxt : une route serveur qui lit la session utilisateur, prend la page dans Referer et fonctionne même quand JavaScript n'est pas chargé.
- [Formulaire de feedback Astro](https://ikiform.com/fr/guides/feedback-form/astro.md): Un formulaire de feedback dans une application Astro : une action qui prend l'utilisateur dans les locals du middleware, la page dans la requête et l'écran dans le motif de route.
- [Formulaire de feedback Next.js](https://ikiform.com/fr/guides/feedback-form/nextjs.md): Un formulaire de feedback dans une application Next.js : une server action qui lit la session, tire la page de la requête et transmet une note avec l'utilisateur joint.
- [Formulaire de signalement de bug](https://ikiform.com/fr/guides/feedback-form/bug-report.md): Un formulaire de signalement de bug qui demande les étapes et la gravité, et laisse la page ajouter elle-même l'URL, le navigateur, le viewport et les erreurs récentes.
- [Formulaire de demande de fonctionnalité](https://ikiform.com/fr/guides/feedback-form/feature-request.md): Un formulaire de demande de fonctionnalité qui demande le problème plutôt que la solution, range les demandes par thème, compte les personnes et non les lignes, et les prévient quand c'est livré.
