Guides18 min de lecture
Ajouter un formulaire de feedback à votre site ou à votre application
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.
Ajoutez un court formulaire avec une note de 1 à 5, un commentaire facultatif et un e-mail facultatif, et envoyez-le à un backend de formulaires pour ne pas gérer de serveur. Envoyez l'adresse de la page avec chaque réponse, car le navigateur ne vous donnera pas l'URL complète de lui-même. Faites remonter les notes basses dans le chat de votre équipe le jour même et gardez toutes les réponses au même endroit pour suivre les tendances.
La plupart des gens ne vous disent jamais ce qui ne va pas sur votre site. Ils partent. Un petit formulaire de feedback capte les quelques personnes qui le diront, au moment où elles le remarquent, sur la page en cause. Ce guide en construit un qui s'adapte à n'importe quel site ou application, envoie la page d'origine, bloque les bots et met les réponses mécontentes sous les yeux de votre équipe le jour même.
Comment fonctionne un formulaire de feedback
Le formulaire envoie à Ikiform. Ikiform valide la note, vérifie que l'envoi n'est pas du spam, le stocke, envoie un e-mail à votre équipe et un webhook signé. À partir du webhook, les notes basses vont dans un canal de chat et chaque réponse peut aller dans un tableur ou une base de données.
- Un visiteur choisit une note, écrit éventuellement un commentaire, et le formulaire l'envoie avec l'adresse de la page.
- Ikiform vérifie que la note est un nombre entier de 1 à 5, lance les vérifications anti-spam et stocke la réponse.
- La réponse apparaît dans le tableau de bord, où vous pouvez la filtrer et l'exporter en CSV.
- Ikiform envoie un e-mail à votre équipe. Quand le visiteur a laissé son e-mail, Répondre lui écrit directement.
- Ikiform envoie un webhook submission.created signé avec les champs et les métadonnées du navigateur.
- Un petit relais ne publie que les notes basses dans le chat de votre équipe, pour qu'elles soient lues aujourd'hui.
Les façons de recueillir du feedback
Chaque type d'outil sert un usage différent. Choisissez selon ce que vous devez voir quand une réponse arrive, pas selon l'apparence du widget.
| Approche | Points forts | Compromis |
|---|---|---|
| Outils de feedback pour sites web, comme Hotjar ou Usersnap | Captures d'écran, annotations et contexte de la page, sans autre code qu'une balise script. | Un script tiers sur chaque page, des réponses stockées dans leur produit et des prix fixés par leurs offres. |
| Le widget d'un outil de suivi d'erreurs, comme Sentry User Feedback | Relie un signalement à l'erreur et, avec le session replay activé, à un enregistrement de ce qui s'est passé. | Conçu pour les bugs, pas pour les avis. Il faut d'abord le SDK de l'outil dans votre application. |
| Les créateurs de formulaires, comme Typeform, Tally ou Google Forms | Des formulaires soignés sans code. Bien pour une enquête envoyée par lien. | Une iframe ou une page à part : la réponse perd donc la page dont elle parle, sauf si vous la passez dans l'URL. |
| Les tableaux de feedback, comme Canny | Vote public et suivi de l'avancement des demandes de fonctionnalités. | Conçu pour les idées, pas pour les réactions rapides. Les réponses sont publiques par principe. |
| Votre propre route API et votre base de données | Contrôle total sur les données et toute la logique. | Vous construisez vous-même le filtrage du spam, les limites de débit, l'e-mail de notification et l'export, et il vous faut un serveur. |
| Un backend de formulaires, comme Ikiform | Votre propre markup sur n'importe quel site ou application. Validation, filtrage du spam, stockage, e-mail et webhooks signés, sans serveur. | Pas de captures d'écran, car l'envoi de fichiers n'est pas pris en charge, ni de vote ou de session replay. |
Si vous avez besoin de captures d'écran sur chaque signalement, un outil de feedback ou de suivi d'erreurs justifie son script. Si vous voulez un formulaire à l'image de votre site, des réponses portables et décider vous-même où elles vont, utilisez un backend de formulaires. C'est ce que fait la suite de ce guide.
Le formulaire
Trois questions suffisent : une note, un commentaire et un e-mail facultatif. La note, c'est ce à quoi les gens répondent ; le commentaire, c'est là qu'est la valeur ; l'e-mail, c'est ce qui vous permet de leur répondre. Choisissez votre stack ci-dessous ; ce choix s'applique à tous les exemples de code de ces guides.
Un seul bloc de markup et un court script. Sans JavaScript, le formulaire envoie quand même et redirige ; avec, le visiteur reste sur la page et voit l'erreur du serveur à côté du bouton. Le script remplit page avec l'adresse sans les paramètres d'URL, pour que les tokens présents dans les URL n'arrivent jamais dans votre boîte de réception.
<form id="feedback" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<fieldset>
<legend>How useful was this page?</legend>
<label><input type="radio" name="rating" value="1" required /> 1</label>
<label><input type="radio" name="rating" value="2" /> 2</label>
<label><input type="radio" name="rating" value="3" /> 3</label>
<label><input type="radio" name="rating" value="4" /> 4</label>
<label><input type="radio" name="rating" value="5" /> 5</label>
</fieldset>
<label for="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows="4" maxlength="4000"></textarea>
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input type="hidden" name="page" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send feedback</button>
<p id="feedback-status" role="status"></p>
</form>
<script>
const form = document.getElementById("feedback");
const status = document.getElementById("feedback-status");
form.elements.page.value = location.origin + location.pathname;
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button");
button.disabled = true;
status.textContent = "Sending";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
form.replaceChildren(Object.assign(document.createElement("p"), { textContent: "Thanks. The team reads every note." }));
return;
}
const fields = result.error.fields ?? {};
status.textContent = fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message;
} catch {
status.textContent = "Couldn't send it. Try again in a moment.";
}
button.disabled = false;
});
</script>La server action lit l'en-tête Referer de son propre envoi. Le formulaire et l'action partagent la même origine, donc le navigateur envoie l'URL complète de la page, et l'action n'en garde que l'origine et le chemin. Aucun input masqué n'est nécessaire.
"use server";
import { headers } from "next/headers";
export interface FeedbackState {
sent: boolean;
error: string;
}
export async function sendFeedback(_previous: FeedbackState, formData: FormData): Promise<FeedbackState> {
const rating = Number(formData.get("rating"));
if (!Number.isInteger(rating) || rating < 1 || rating > 5) return { sent: false, error: "Pick a score from 1 to 5." };
const incoming = await headers();
const referer = incoming.get("referer");
const origin = incoming.get("origin");
const page = referer ? new URL(referer) : null;
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
rating,
comment: String(formData.get("comment") ?? ""),
email: String(formData.get("email") ?? ""),
page: page ? page.origin + page.pathname : "",
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { sent: true, error: "" };
const fields = result?.error?.fields ?? {};
return { sent: false, error: fields.comment?.[0] ?? fields.email?.[0] ?? "Couldn't send it. Try again." };
}"use client";
import { useActionState } from "react";
import { type FeedbackState, sendFeedback } from "./actions";
const initial: FeedbackState = { sent: false, error: "" };
export function FeedbackForm() {
const [state, action, pending] = useActionState(sendFeedback, initial);
if (state.sent) return <p role="status">Thanks. The team reads every note.</p>;
return (
<form action={action}>
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label key={score}>
<input type="radio" name="rating" value={score} required /> {score}
</label>
))}
</fieldset>
<label htmlFor="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows={4} maxLength={4000} />
<label htmlFor="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.error ? <p role="alert">{state.error}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
</form>
);
}Pour aller plus loin avec Next.js, lisez Créer un formulaire de feedback dans l'application avec Next.js.
La page connaît sa propre adresse pendant le rendu, donc Astro.url remplit l'input masqué page côté serveur. Les inputs vides arrivent à l'action sous la forme null, qu'Ikiform traite comme absents. La page doit être rendue à la demande pour que le résultat de l'action s'affiche.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
sendFeedback: defineAction({
accept: "form",
input: z.object({
rating: z.coerce.number().int().min(1).max(5),
comment: z.string().max(4000).nullish(),
email: z.email().nullish(),
page: z.string().nullish(),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(input),
});
const result = await response.json();
if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.error.message });
return { sent: true };
},
}),
};---
import { actions, isInputError } from "astro:actions";
const result = Astro.getActionResult(actions.sendFeedback);
const page = Astro.url.origin + Astro.url.pathname;
---
{result?.data?.sent ? (
<p role="status">Thanks. The team reads every note.</p>
) : (
<form method="POST" action={actions.sendFeedback}>
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label><input type="radio" name="rating" value={score} required /> {score}</label>
))}
</fieldset>
<label for="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows="4" maxlength="4000"></textarea>
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input type="hidden" name="page" value={page} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && (
<p role="alert">{isInputError(result.error) ? "Pick a score and check your email address." : result.error.message}</p>
)}
<button>Send feedback</button>
</form>
)}Pour aller plus loin avec Astro, lisez Créer un formulaire de feedback dans l'application avec Astro.
Une server function POST vérifie la note avant que quoi que ce soit ne quitte votre serveur. Le composant renseigne page depuis window.location au moment de l'envoi, pour qu'elle soit juste même après une navigation côté client.
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
const sendFeedback = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const rating = Number(data.get("rating"));
if (!Number.isInteger(rating) || rating < 1 || rating > 5) throw new Error("Pick a score from 1 to 5.");
return {
rating,
comment: String(data.get("comment") ?? ""),
email: String(data.get("email") ?? ""),
page: String(data.get("page") ?? ""),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }) => {
const origin = getRequestHeader("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(data),
});
const result = await response.json();
if (result.ok) return { sent: true, error: "" };
const fields = result.error.fields ?? {};
return { sent: false, error: String(fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message) };
});
export function FeedbackForm() {
const send = useServerFn(sendFeedback);
const [state, setState] = useState({ sent: false, error: "" });
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
data.set("page", window.location.origin + window.location.pathname);
setPending(true);
try {
setState(await send({ data }));
} catch (error) {
setState({ sent: false, error: error instanceof Error ? error.message : "Couldn't send it. Try again." });
} finally {
setPending(false);
}
}
if (state.sent) return <p role="status">Thanks. The team reads every note.</p>;
return (
<form method="post" onSubmit={onSubmit}>
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label key={score}>
<input type="radio" name="rating" value={score} required /> {score}
</label>
))}
</fieldset>
<label htmlFor="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows={4} maxLength={4000} />
<label htmlFor="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.error ? <p role="alert">{state.error}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
</form>
);
}Pour aller plus loin avec TanStack Start, lisez Créer un formulaire de feedback dans l'application avec TanStack Start.
Une resource route contient l'action, et useFetcher y envoie depuis n'importe quelle page sans navigation. Déclarez la route comme api/feedback dans app/routes.ts. useLocation fournit le chemin, et l'action le résout par rapport à sa propre URL, qui a l'origine de votre site.
import { data } from "react-router";
import type { Route } from "./+types/api.feedback";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const origin = request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
rating: String(formData.get("rating") ?? ""),
comment: String(formData.get("comment") ?? ""),
email: String(formData.get("email") ?? ""),
page: new URL(String(formData.get("path") ?? "/"), request.url).href,
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { sent: true, error: "" };
const fields = result.error.fields ?? {};
return data(
{ sent: false, error: String(fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message) },
{ status: response.status },
);
}import { useFetcher, useLocation } from "react-router";
import type { action } from "../routes/api.feedback";
export function FeedbackForm() {
const fetcher = useFetcher<typeof action>();
const { pathname } = useLocation();
const pending = fetcher.state !== "idle";
if (fetcher.data?.sent) return <p role="status">Thanks. The team reads every note.</p>;
return (
<fetcher.Form method="post" action="/api/feedback">
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label key={score}>
<input type="radio" name="rating" value={score} required /> {score}
</label>
))}
</fieldset>
<label htmlFor="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows={4} maxLength={4000} />
<label htmlFor="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input type="hidden" name="path" value={pathname} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{fetcher.data?.error ? <p role="alert">{fetcher.data.error}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
</fetcher.Form>
);
}Pour aller plus loin avec React Router, lisez Créer un formulaire de feedback dans l'application avec React Router.
Un endpoint +server.ts transmet l'envoi, pour que le composant puisse vivre dans un layout et fonctionner sur toutes les pages. page depuis $app/state donne l'URL courante. Déclarez IKIFORM_SECRET_KEY dans src/env.ts avec defineEnvVars pour que $app/env/private puisse la lire. L'endpoint renvoie Response.json(), car SvelteKit 3 rend obsolète le helper json().
import { error } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, fetch, url }) => {
const body = await request.json();
const rating = Number(body.rating);
if (!Number.isInteger(rating) || rating < 1 || rating > 5) error(400, "Pick a score from 1 to 5.");
const 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(body.comment ?? ""),
email: String(body.email ?? ""),
page: String(body.page ?? ""),
_gotcha: String(body._gotcha ?? ""),
}),
});
const result = await response.json();
if (!result.ok) {
const fields = result.error.fields ?? {};
error(response.status, String(fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message));
}
return Response.json({ sent: true });
};<script lang="ts">
import { page } from "$app/state";
let rating = $state(0);
let comment = $state("");
let email = $state("");
let trap = $state("");
let status = $state<"idle" | "sending" | "sent">("idle");
let message = $state("");
async function send(event: SubmitEvent) {
event.preventDefault();
status = "sending";
message = "";
const response = await fetch("/api/feedback", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ rating, comment, email, page: page.url.origin + page.url.pathname, _gotcha: trap }),
});
if (response.ok) {
status = "sent";
return;
}
message = (await response.json().catch(() => null))?.message ?? "Couldn't send it. Try again.";
status = "idle";
}
</script>
{#if status === "sent"}
<p role="status">Thanks. The team reads every note.</p>
{:else}
<form onsubmit={send}>
<fieldset>
<legend>How useful was this page?</legend>
{#each [1, 2, 3, 4, 5] as score}
<label><input type="radio" name="rating" value={score} bind:group={rating} required /> {score}</label>
{/each}
</fieldset>
<label for="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows="4" maxlength="4000" bind:value={comment}></textarea>
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" bind:value={email} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden bind:value={trap} />
{#if message}
<p role="alert">{message}</p>
{/if}
<button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send feedback"}</button>
</form>
{/if}Pour aller plus loin avec SvelteKit, lisez Créer un formulaire de feedback dans l'application avec SvelteKit.
Une route serveur garde la clé dans runtimeConfig.ikiformSecretKey, alimentée par NUXT_IKIFORM_SECRET_KEY. useRoute().path et useRequestURL() construisent l'adresse de la page aussi bien côté serveur que dans le navigateur.
export default defineEventHandler(async (event) => {
const body = await readBody<{ rating?: number; comment?: string; email?: string; page?: string; _gotcha?: string }>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
rating: body.rating,
comment: body.comment ?? "",
email: body.email ?? "",
page: body.page ?? "",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
const fields = result.error.fields ?? {};
throw createError({
status: response.status,
message: String(fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message),
});
}
return { sent: true };
});<script setup lang="ts">
const route = useRoute();
const requestUrl = useRequestURL();
const rating = ref<number | null>(null);
const comment = ref("");
const email = ref("");
const trap = ref("");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
async function send() {
status.value = "sending";
error.value = "";
try {
await $fetch("/api/feedback", {
method: "POST",
body: {
rating: rating.value,
comment: comment.value,
email: email.value,
page: requestUrl.origin + route.path,
_gotcha: trap.value,
},
});
status.value = "sent";
} catch (failure) {
error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send it. Try again.";
status.value = "idle";
}
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks. The team reads every note.</p>
<form v-else @submit.prevent="send">
<fieldset>
<legend>How useful was this page?</legend>
<label v-for="score in 5" :key="score">
<input v-model="rating" type="radio" name="rating" :value="score" required /> {{ score }}
</label>
</fieldset>
<label for="comment">What would make it better?</label>
<textarea id="comment" v-model="comment" name="comment" rows="4" maxlength="4000" />
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" v-model="email" name="email" type="email" />
<input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send feedback" }}</button>
</form>
</template>Pour aller plus loin avec Nuxt, lisez Créer un formulaire de feedback dans l'application avec Nuxt.
Sans serveur à vous, le navigateur envoie directement à Ikiform. submit() de @ikiform/sdk envoie le FormData et renvoie { ok } ou les erreurs de champ. Ne mettez jamais de clé secrète ici ; c'est Allowed domains (domaines autorisés) qui protège le formulaire.
import { submit } from "@ikiform/sdk";
import { type FormEvent, useState } from "react";
const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
export function FeedbackForm() {
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
data.set("page", window.location.origin + window.location.pathname);
setStatus("sending");
setError("");
const result = await submit(FORM_ID, data);
if (result.ok) {
setStatus("sent");
return;
}
const fields = result.error.fields ?? {};
setError(fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message);
setStatus("idle");
}
if (status === "sent") return <p role="status">Thanks. The team reads every note.</p>;
return (
<form onSubmit={onSubmit}>
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label key={score}>
<input type="radio" name="rating" value={score} required /> {score}
</label>
))}
</fieldset>
<label htmlFor="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows={4} maxLength={4000} />
<label htmlFor="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send feedback"}</button>
</form>
);
}Le même envoi côté navigateur dans un composant monofichier. Le libellé du commentaire change quand la note est 1 ou 2, ce qui obtient des réponses plus précises des visiteurs mécontents.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const rating = ref(0);
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
async function send(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
body.set("page", window.location.origin + window.location.pathname);
status.value = "sending";
error.value = "";
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
status.value = "sent";
return;
}
const fields = result.error.fields ?? {};
error.value = fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message;
} catch {
error.value = "Couldn't send it. Try again in a moment.";
}
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks. The team reads every note.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<fieldset>
<legend>How useful was this page?</legend>
<label v-for="score in 5" :key="score">
<input v-model="rating" type="radio" name="rating" :value="score" required /> {{ score }}
</label>
</fieldset>
<label for="comment">{{ rating && rating <= 2 ? "What went wrong?" : "What would make it better?" }}</label>
<textarea id="comment" name="comment" rows="4" maxlength="4000" />
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send feedback" }}</button>
</form>
</template>- Remplacez
frm_xxxxxxxxxxxxxxxxpar l'id de votre formulaire, visible dans le tableau de bord. ratingest un groupe de cinq boutons radio. Ikiform le stocke comme un nombre, donc les moyennes ne demandent aucun parsing.emailest facultatif. Les gens écrivent plus franchement quand ils n'ont pas à s'identifier, et ceux qui veulent une réponse le laisseront.- L'input masqué
_gotchaest le honeypot. Les humains ne le voient jamais ; les bots qui remplissent tous les inputs se signalent eux-mêmes comme spam. pageenregistre la page dont parle la réponse. La section suivante explique pourquoi vous l'envoyez vous-même.
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Les guides par framework de la documentation détaillent la mise en place : Next.js, Astro, SvelteKit, Nuxt, React, Vue et HTML simple. Le modèle de formulaire de feedback génère les mêmes champs en HTML, en React et en server action ; les modèles de signalement de bug et d'enquête couvrent les formulaires plus longs.
Définir les champs
Collez une définition dans l'onglet Fields du formulaire et Ikiform l'applique à chaque envoi, depuis n'importe quelle page. Essayez ci-dessous : l'aperçu exécute le validateur d'Ikiform dans votre navigateur, donc une note de 0 ou un e-mail mal formé affiche le 422 que vous recevriez.
Aperçu
Exécute le validateur d'Ikiform dans votre navigateur. Rien n'est envoyé.
rating accepte un nombre entier de 1 à son max, ici 5. Il peut aller jusqu'à 10, mais cinq choix sont plus rapides.| Champ | Type | Pourquoi |
|---|---|---|
rating | rating, max 5 | Un clic. Stocké comme un nombre, pour faire la moyenne par page |
comment | textarea, jusqu'à 4 000 caractères | La raison derrière la note |
email | email, facultatif | Répondre aux personnes qui posent une question. Devient l'adresse de réponse de la notification |
page | url, facultatif | La page dont parle la réponse |
Une fois les champs définis, tout autre nom dans l'envoi est refusé avec Unknown field. Si plus tard vous envoyez plus de contexte, comme une version d'application ou un nom d'offre, ajoutez d'abord un champ pour lui. Les id de champ sont en snake_case minuscule.
Savoir de quelle page il s'agit
Une note sans page est difficile à exploiter. Ikiform enregistre les métadonnées du navigateur avec chaque soumission, mais l'une de ces valeurs est moins utile qu'elle n'en a l'air :
| Valeur | D'où elle vient | Ce que vous obtenez |
|---|---|---|
meta.user_agent | Enregistré par Ikiform | Le navigateur et le système d'exploitation, depuis un envoi du navigateur |
meta.origin | Enregistré par Ikiform | L'origine de votre site, comme https://example.com |
meta.referrer | Enregistré par Ikiform | Généralement l'origine seule. Les navigateurs appliquent par défaut la politique strict-origin-when-cross-origin, qui supprime le chemin lors d'un envoi vers un autre site |
meta.country | Enregistré par Ikiform | Un code pays à deux lettres |
page | Envoyé par votre formulaire | Le chemin complet, pour savoir de quelle page parle la réponse |
C'est pourquoi chaque exemple ci-dessus envoie page lui-même. Les versions HTML et SPA lisent location ; les versions serveur lisent l'adresse que leur framework connaît déjà. Toutes suppriment les paramètres d'URL, qui peuvent contenir des termes de recherche, des id de suivi ou des tokens à usage unique que vous ne voulez pas stocker.
meta.user_agent est le runtime de votre serveur, pas le navigateur du visiteur. Pour le feedback sur une page, c'est rarement important. Pour les signalements de bug, ça l'est, et le guide du signalement de bug transmet le user agent du navigateur dans un champ.Où poser la question
L'emplacement du formulaire détermine ce que vous entendez. Un formulaire dans le pied de page de chaque page reçoit un filet d'avis tranchés. Une question à la fin d'une tâche reçoit des réponses régulières et précises.
| Emplacement | Idéal pour | Points de vigilance |
|---|---|---|
| Fin d'une page de documentation ou d'un article | Repérer les pages qui ne répondent pas à la question | Peu de réponses par page. Lisez-les par section, pas par page |
| Après une tâche dans votre application, comme un export ou un premier projet | Le ressenti d'un parcours précis, tant qu'il est frais | Demandez une fois par personne, pas à chaque fois qu'elle termine la tâche |
| Un bouton flottant sur chaque écran | N'importe quoi, n'importe quand. Voir le widget React | Il ne doit pas masquer vos propres boutons, bannières de cookies ou bulles de chat |
| Un lien dans un e-mail | Les scores à suivre dans le temps, comme le NPS | Le client e-mail n'est pas votre page : le formulaire a donc besoin d'une page d'atterrissage sur votre site |
Gardez le formulaire court, où qu'il soit. Demandez d'abord la note et affichez le champ de commentaire avec, pas dans une deuxième étape. Une deuxième étape fait fuir la plupart des gens, et une note sans raison reste utile.
Mettre les notes basses sous les yeux de quelqu'un
- Chaque réponse est stockée, quelle que soit sa note et la page d'où elle vient.
- Ikiform envoie un webhook submission.created signé à votre relais.
- Le relais lit la note. Les notes de 3 à 5 ne demandent rien de plus dans l'immédiat.
- Une note de 1 ou 2 part dans le chat de votre équipe avec la page et le commentaire.
- Si le visiteur a laissé son e-mail, quelqu'un lui répond le jour même.
- Une fois par semaine, l'export donne les moyennes par page, et c'est là que les tendances apparaissent.
Les e-mails de notification vont à 10 destinataires confirmés au plus par formulaire et arrivent pour chaque réponse. Ça convient pour quelques réponses par jour. Au-delà, envoyez les notes basses dans le chat avec un relais comme cette route Next.js ; le même code tourne dans un Cloudflare Worker :
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export async function POST(request: Request) {
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const { rating, comment, page, email } = event.data.fields;
if (Number(rating) > 2) return new Response(null, { status: 204 });
const lines = [
`*${rating}/5* on ${page ?? event.data.meta.origin ?? "an unknown page"}`,
comment ? `> ${String(comment).slice(0, 500)}` : "_No comment._",
email ? `Reply to ${email}` : "Anonymous",
];
const response = await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: lines.join("\n") }),
});
if (!response.ok) return new Response(`Slack answered ${response.status}`, { status: 502 });
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
}verifyWebhookvérifie la signature et l'horodatage, pour que personne d'autre ne puisse publier dans votre canal via cette URL.- Les notes supérieures à 2 répondent
204et s'arrêtent là. Ikiform considère tout 2xx comme livré. - Un appel Slack en échec répond
502, et Ikiform réessaie avec le mêmewebhook-id: une courte panne ne fait donc pas perdre d'alerte. - Les valeurs des champs sont ce que le visiteur a tapé. Le relais coupe le commentaire à 500 caractères avant qu'il n'arrive dans le chat.
Ajoutez l'endpoint sous Webhooks et copiez son secret whsec_ dans IKIFORM_WEBHOOK_SECRET. Un espace de travail peut avoir 3 endpoints. Pour des routes sans code, voyez Slack, Discord et Google Sheets. Pour un feedback qui doit devenir un ticket, Linear et GitHub Issues propose un relais qui en crée un par soumission.
Tenir les bots à l'écart
Un formulaire de feedback sur chaque page, c'est un formulaire public sur chaque page. Les bots le trouvent, et ils envoient surtout des liens. Les réglages par défaut d'Ikiform en gèrent l'essentiel :
| Défense | Ce qu'elle arrête | Configuration |
|---|---|---|
| Honeypot | Les bots qui remplissent tous les inputs | L'input masqué _gotcha des exemples |
| Score de spam | Trois liens ou plus (+2), phrases de spam connues (+2), e-mails jetables (+2), répétitions identiques (+3). À 5, c'est stocké comme spam | Aucun |
| Limites de débit | Les scripts qui envoient en boucle | Activée par défaut : Standard, 5 par minute et par visiteur, et 300 par minute pour l'ensemble du formulaire |
| Domaines autorisés | Les envois depuis d'autres sites | Ajoutez votre domaine dans les réglages du formulaire |
| Vérification humaine | Les bots qui pilotent un vrai navigateur | Réglez Spam check sur Ikiform. Les envois fetch du navigateur ont alors besoin d'un token de challenge() |
Le spam reçoit la même réponse de succès qu'un vrai envoi, ne déclenche ni e-mail ni webhook et ne compte pas dans l'utilisation. Un vrai visiteur qui colle trois liens dans un commentaire peut déclencher la règle des liens en même temps qu'une répétition : jetez donc un œil au filtre Spam de temps en temps. Marquer une réponse comme non-spam envoie alors son webhook. La documentation anti-spam explique chaque vérification.
Lire les réponses
Lisez chaque commentaire les premières semaines. Ensuite, la vue utile est la note moyenne par page, la plus basse en premier, avec les commentaires à côté.
Export
Ouvrez Submissions, choisissez le formulaire de feedback, une période, puis Export CSV. Les colonnes sont id, created_at, form et status, puis une par champ : avec les champs ci-dessus, rating est la colonne E et page la colonne H. Un export contient jusqu'à 100 000 lignes ; le guide d'export présente l'API pour aller au-delà.
Importez le fichier dans Google Sheets et mettez ceci dans une feuille vide. Il liste chaque page avec sa note moyenne et son nombre de réponses, de la pire à la meilleure :
=QUERY(A2:H, "select H, avg(E), count(E) where D = 'received' and E is not null group by H order by avg(E) asc label avg(E) 'average', count(E) 'answers'", 0)- Ignorez les pages qui n'ont qu'une ou deux réponses. Un visiteur en colère ne fait pas une tendance.
- Lisez les commentaires des trois pages les moins bien notées. La correction se trouve généralement dans les premiers.
- Après avoir modifié une page, comparez sa moyenne sur les semaines d'avant et d'après, pas la moyenne globale.
Choisissez votre cas
Le formulaire ci-dessus couvre les réactions rapides sur n'importe quelle page. Ces pages-ci couvrent le feedback qui a un autre rôle : un bug à reproduire, une demande à évaluer, un score à suivre ou un client sur le départ.
- Formulaire de signalement de bugUn 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é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é.
- Widget de feedback ReactUn bouton de feedback flottant pour une application React qui ouvre un petit panneau, envoie l'écran, la version et le compte avec chaque réponse, et ne perd jamais un brouillon.
- Feedback des bêta-testeursUn formulaire de feedback bêta avec un lien personnel par testeur et par build, deux échelles de notation et un résumé hebdomadaire qui montre si chaque build s'est amélioré.
- Enquête NPSMenez une enquête NPS avec un score de 0 à 10 dans l'e-mail, une question de suivi sur votre propre page qui change selon le score, et un script qui calcule le NPS.
- Enquête de résiliationDemandez pourquoi vos clients résilient dans le parcours d'annulation de votre SaaS, gardez Passer à un clic, enregistrez la raison dans Stripe et Ikiform, sans jamais retarder la résiliation.
- Formulaire de feedback Next.jsUn 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 feedback AstroUn 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 TanStack StartUn formulaire de feedback dans une application TanStack Start : une server function POST avec un middleware de session, l'id de route comme contexte et la page lue dans les en-têtes.
- Formulaire de feedback React RouterUn formulaire de feedback React Router 8 : l'action d'une resource route lit la session cookie, un fetcher envoie depuis le layout et un envoi sans JS redirige en retour.
- Formulaire de feedback SvelteKitUn 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.
- Formulaire de feedback NuxtUn 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é.
Vérifiez que tout fonctionne
Faites ces vérifications avant de mettre le formulaire sur chaque page. Ikiform n'a pas d'offre gratuite. Les tests sont gratuits : un espace de travail sans offre accepte 50 soumissions de test par mois depuis localhost. Pour passer en production, choisissez Pro à partir de 6 $/mois pour 500 soumissions, ou payez une seule fois : 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais. Voir les tarifs.
- Dans Allowed domains du formulaire, ajoutez le domaine de votre site, plus
localhostpendant le développement. Laissez vide pour accepter les envois de partout. - Choisissez une note, écrivez un commentaire et envoyez. La ligne de remerciement remplace le formulaire, ou le navigateur arrive sur votre page de redirection pour la version HTML simple.
- Ouvrez Submissions dans le tableau de bord. La réponse affiche
ratingcomme un nombre etpageavec le chemin complet. - Envoyez une note de
6aveccurlou la console du navigateur. Ikiform répond422en nommantrating. - Vérifiez la boîte de réception de chaque destinataire des notifications. Chacun reçoit un e-mail avec les champs, et Reply répond à l'expéditeur quand le formulaire a un champ
email. - Envoyez une note de 1. Votre relais la publie dans le chat de l'équipe, et la livraison sous Webhooks affiche
succeeded. - Envoyez avec le honeypot rempli,
_gotcha=bot. L'envoi reçoit quand même une réponse de succès, mais la soumission est stockée comme spam et n'envoie ni e-mail ni webhook. - Rien n'est arrivé ? Passez le filtre de statut du tableau de bord sur Spam. Le spam reçoit la même réponse de succès qu'une vraie soumission.
Dépannage
| Problème | Que faire |
|---|---|
403 origin_not_allowed, ou tous les envois finissent en spam | Le domaine de la page n'est pas dans les Allowed domains du formulaire. Ajoutez-le, plus localhost pour les tests en local. Les sous-domaines d'un domaine listé sont autorisés. |
422 avec Rating must be at most 5 | Le formulaire envoie une note supérieure au max du champ. Changez les valeurs des boutons radio ou augmentez max dans la définition, jusqu'à 10. |
422 avec un Unknown field | Le formulaire envoie un nom absent de la définition, souvent un nouvel input masqué. Ajoutez un champ avec cet id, ou supprimez l'input. |
page est vide | Le script qui le remplit ne s'est pas exécuté, ou s'est exécuté avant que le formulaire n'existe. Placez-le après le formulaire, ou renseignez page au moment de l'envoi comme le font les exemples SPA. |
meta.referrer n'affiche que votre domaine | C'est normal. Par défaut, les navigateurs n'envoient que l'origine sur les envois cross-site. Utilisez plutôt le champ page. |
| La soumission est dans le tableau de bord mais aucun e-mail ni webhook n'est arrivé | Elle a été stockée comme spam. Le spam n'envoie rien. Ouvrez-la sous le filtre Spam pour voir les raisons, et marquez-la comme non-spam pour envoyer son e-mail et ses webhooks. |
429 rate_limited | Un visiteur a envoyé plus que ce que permet la limite de débit du formulaire. Standard autorise 5 envois par minute et par visiteur. Attendez la durée de Retry-After, ou choisissez Relaxed ou Custom pour les formulaires qui envoient depuis une seule IP partagée. |
402 test_limit_reached | L'espace de travail n'a pas d'offre et a utilisé ses 50 tests gratuits depuis localhost ce mois-ci. Choisissez une offre, ou utilisez Send test dans le tableau de bord. |
Questions
Faites pointer l'action du formulaire vers l'endpoint d'un backend de formulaires. Le formulaire peut être du HTML simple sur un site statique ; le backend valide, stocke et envoie par e-mail chaque réponse, et envoie un webhook si vous la voulez ailleurs.
Envoyez l'adresse de la page dans un champ. Ikiform enregistre aussi le referrer, mais les navigateurs n'envoient généralement que votre domaine sur un envoi cross-site : un champ de page rempli depuis location est donc la méthode fiable.
Rendez l'e-mail facultatif. Les gens sont plus francs sans, et ceux qui veulent une réponse le laisseront. Ikiform enregistre quand même le navigateur, l'origine, le referrer et le pays avec chaque réponse.
Pas sous forme de fichier. Ikiform refuse les envois avec fichiers avec 422 files_not_supported. Demandez plutôt un lien vers une capture, ou utilisez un outil de feedback centré sur les captures d'écran s'il vous en faut une sur chaque signalement.
Pas avec un champ rating, qui va de 1 à 10 au plus. Un score de 0 à 10 utilise un champ number avec min 0, max 10 et integer true. Le guide NPS montre la configuration complète.
Ajoutez un webhook vers un petit relais qui vérifie la note et ne publie que les notes basses dans Slack ou Discord. Les e-mails de notification couvrent chaque réponse ; le relais couvre celles qui demandent une réponse aujourd'hui.
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.