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.
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 :
| 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.
- L'utilisateur note l'écran dans le formulaire du layout. use:enhance l'envoie à /feedback?/send sans chargement de page.
- Le hook handle s'exécute en premier, lit le cookie de session et place l'utilisateur dans event.locals.
- 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.
- 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.
- update avec navigate à false applique le résultat à l'écran courant : la ligne de remerciement s'affiche là où se trouve l'utilisateur.
Mise en place 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
.envCré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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyimport { 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é.
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.
declare global {
namespace App {
interface Locals {
user: { id: string; email: string } | null;
}
}
}
export {};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
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;failrenvoie un statut et des données sans lever d'exception. Dans SvelteKit 3, un formulaire amélioré reçoit aussi ce statut :401et400apparaissent donc tels quels dans l'onglet réseau.pageFromne garde unRefererque s'il vient de votre propre origine, et supprime les paramètres d'URL.export const prerender = falseest 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
Origintransmet 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 commelocalhost, voir plus bas. - Un
429d'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
<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: falsegarde l'utilisateur sur son écran et place le résultat ici, danspage.form. refreshAll: falseévite de relancer toutes les fonctionsloadaprè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.formappartient à la page courante. Quand l'utilisateur navigue, il se vide, et l'écran suivant affiche un formulaire vierge.
<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 :
<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é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
- Lancez
vite dev, connectez-vous, ouvrez l'écran de réglages d'un projet et envoyez une note de 2 avec un commentaire. - Ouvrez Submissions.
screenvaut/projects/[id]/settings,pagecontient le vrai id du projet, etuser_idetemailcorrespondent à votre compte. - Effacez le cookie de session et envoyez à nouveau. Le formulaire affiche le message de connexion et garde votre commentaire.
- Désactivez JavaScript et envoyez. Vous arrivez sur
/feedbackavec la ligne de remerciement et un lien vers l'écran. - 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
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.
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.
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.
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.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.