Formulaire de feedback pour un site ou une application

Formulaire de feedback pour un site ou une application9 min de lecture

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.

Preet SutharFondateur, Ikiform

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

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 :

ValeurSourceConfiance
rating, commentLe formulaireCe que l'utilisateur veut dire
user_id, emailevent.locals.user, renseigné par le hook handleLu dans le cookie de session, côté serveur
pageLe Referer de la requête de l'actionMême origine uniquement ; tout le reste est écarté
screenpage.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.

Comment un envoi de feedback SvelteKit passe d'un formulaire de layout à une seule form actionformulairedulayouttoutécran,use:enhancehookhandlesessionverslocals.useraction ?/sendvérifieutilisateuretnoteIkiformclésecrète,stockepage.formmercisurlemêmeécranfail(401ou400)messagesousleformulairePOST /feedbacklocals+ utilisateur, pagenavigate: falsepas d'utilisateur ou note invalideComment un envoi de feedback SvelteKit passe d'un formulaire de layout à une seule form actionformulaire dulayouttout écran,use:enhancehook handlesession verslocals.useraction ?/sendvérifieutilisateur etnoteIkiformclé secrète, stockepage.formmerci sur le mêmeécranfail(401ou 400)message sousleformulairePOST /feedbacklocals+ utilisateur, pagenavigate: falsepas d'utilisateur ou note invalide
  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
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
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
src/env.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.

Aperçu

Exécute le validateur d'Ikiform dans votre navigateur. Rien n'est envoyé.

Comment cet écran fonctionne-t-il pour vous ?

Renseigné par votre serveur à partir de la requête.

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

La 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 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
<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
<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
<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 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ébergeurAdaptateur 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èmeQue faire
Le remerciement s'affiche sur /feedback au lieu de l'écran courantLe 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'actionLe 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 /feedbackUne page avec des actions ne peut pas être prérendue. Gardez export const prerender = false dans son +page.server.ts.
422 validation_failedL'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

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.