Formulaire de feedback pour un site ou une application9 min de lecture
Créer un formulaire de feedback dans l'application avec Nuxt
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é.
Envoyez un formulaire à deux questions vers une route serveur Nuxt dans server/api/feedback.post.ts. La route lit l'utilisateur connecté avec getUserSession de nuxt-auth-utils, prend la page dans le Referer de la requête, valide la note avec Zod et transmet le tout à votre backend de formulaires avec une clé secrète gardée dans runtimeConfig. Avec JavaScript, le formulaire envoie via $fetch et reste en place ; sans, la même route redirige en retour avec le résultat dans l'URL.
L'onglet Nuxt du guide principal construit un feedback de page pour tout visiteur. Dans une application Nuxt avec des comptes, la tâche est plus étroite et les données plus riches : l'utilisateur est connecté, le serveur sait qui il est et le routeur sait sur quel écran il se trouve. Cette page construit cette version. Les clés, les domaines autorisés et le reste de la configuration sont dans le guide Nuxt de la documentation.
Ce que change une application Nuxt avec connexion
Dans un formulaire public, c'est le navigateur qui décide de ce qui est stocké. Ici, le navigateur ne décide que de la note et du commentaire. Tout ce qui dit qui et où est ajouté par la route serveur, où personne ne peut le modifier :
| Champ | Renseigné par | Comment |
|---|---|---|
rating, comment | La personne elle-même | Des boutons radio et une zone de texte |
user_id, email | La server route | getUserSession(event) lit le cookie de session scellé |
page | La server route | L'en-tête Referer, que le navigateur envoie avec le chemin complet sur les envois de même origine |
screen | Le composant, vérifié par la route | useRoute().name, comme projects-id-settings, construit par Nuxt à partir du chemin du fichier de la page |
Les noms de route sont une clé de regroupement gratuite dans Nuxt. La page de réglages de chaque projet partage projects-id-settings : vous pouvez donc faire la moyenne des notes par écran, tandis que page garde l'adresse exacte.
- Après l'hydratation, le composant bloque l'envoi natif et envoie les champs en JSON avec $fetch.
- La route serveur vérifie l'Origin, lit l'utilisateur dans la session, valide la note et ajoute la page issue de Referer.
- Elle transmet la réponse à Ikiform avec la clé secrète tirée de runtimeConfig.
- Une requête JSON reçoit { sent: true } ou une erreur avec un message, et le formulaire se met à jour sur place.
- Avant l'hydratation, le navigateur envoie lui-même le formulaire. La route effectue les mêmes vérifications et le même envoi.
- Elle répond ensuite par un 303 vers la page, avec le résultat dans les paramètres d'URL pour que le composant l'affiche.
Mise en place du projet
Ceci utilise l'organisation de Nuxt 4, avec le code de l'application sous app/ et le code serveur à la racine. Installez nuxt-auth-utils et zod. Si votre application connecte déjà les gens d'une autre façon, gardez-la et lisez plutôt l'utilisateur depuis votre propre session dans la route.
app/
components/FeedbackForm.vue
pages/projects/[id]/settings.vue
server/
api/feedback.post.ts
shared/
types/auth.d.ts
nuxt.config.ts
.envDéclarez la clé secrète dans runtimeConfig avec une valeur par défaut vide. Nuxt la remplit à l'exécution depuis NUXT_IKIFORM_SECRET_KEY, et comme la clé n'est pas sous public, elle n'existe que côté serveur.
export default defineNuxtConfig({
modules: ["nuxt-auth-utils"],
runtimeConfig: {
ikiformSecretKey: "",
},
});NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_SESSION_PASSWORD=at-least-32-characters-long-random-stringCréez la clé sous API keys avec le scope submissions:write. nuxt-auth-utils scelle son cookie de session avec NUXT_SESSION_PASSWORD, qui doit faire au moins 32 caractères ; en développement, il en écrit un pour vous au premier lancement. Indiquez-lui à quoi ressemble votre utilisateur, pour que session.user.id soit typé :
declare module "#auth-utils" {
interface User {
id: string;
email: string;
}
}
export {};Collez ensuite la définition dans l'onglet Fields du formulaire. Les deux premiers champs forment le formulaire ; la route serveur ajoute 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 affiche donc le 422 que recevrait la route.La construire
La server route
import { z } from "zod";
const Feedback = z.object({
rating: z.coerce.number().int().min(1).max(5),
comment: z.string().trim().max(4000).default(""),
screen: z.string().regex(/^[a-z0-9_-]{1,120}$/).catch("unknown"),
_gotcha: z.string().default(""),
});
function pageFrom(referer: string | undefined) {
try {
const url = new URL(referer ?? "");
return url.origin + url.pathname;
} catch {
return "";
}
}
export default defineEventHandler(async (event) => {
const own = getRequestURL(event, { xForwardedHost: true });
if (getRequestHeader(event, "origin") !== own.origin) {
throw createError({ status: 403, message: "Posts from other sites are refused." });
}
const wantsJson = getRequestHeader(event, "content-type")?.includes("application/json") ?? false;
const referer = getRequestHeader(event, "referer");
const back = (outcome: string) => {
const target = new URL(referer ?? "/", own.origin);
return sendRedirect(event, `${target.pathname}?feedback=${outcome}`, 303);
};
const session = await getUserSession(event);
if (!session.user) {
if (wantsJson) throw createError({ status: 401, message: "Your session ended. Sign in again, then send it." });
return back("signed-out");
}
const parsed = await readValidatedBody(event, (body) => Feedback.safeParse(body));
if (!parsed.success) {
if (wantsJson) throw createError({ status: 422, message: "Pick a score from 1 to 5." });
return back("invalid");
}
const { ikiformSecretKey } = useRuntimeConfig(event);
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${ikiformSecretKey}`,
Origin: own.origin,
},
body: JSON.stringify({
...parsed.data,
page: pageFrom(referer),
user_id: session.user.id,
email: session.user.email,
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return wantsJson ? { sent: true } : back("sent");
if (wantsJson) {
throw createError({ status: 502, message: result?.error?.message ?? "We couldn't send it. Try again." });
}
return back("failed");
});- Le suffixe
.post.tsfait que le fichier ne répond qu'àPOST. - La vérification d'Origin passe avant tout le reste.
readBody, qu'utilisereadValidatedBody, analyse aussi les corps urlencoded : sans elle, un autre site pourrait envoyer un simple formulaire vers cette route avec le cookie de votre utilisateur joint. getUserSessionne lève pas d'exception. La route choisit elle-même la réponse : un401pour$fetch, une redirection pour un envoi classique.requireUserSessionenverrait une page401à un utilisateur sans JavaScript.z.coerce.number()transforme le"4"urlencoded en4..catch("unknown")stocke unscreenmal formé commeunknownau lieu de refuser toute la réponse.backne garde que le chemin deReferer, pour qu'une redirection ne puisse jamais quitter votre site.
Le composant
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
"signed-out": "Your session ended. Sign in again, then send it.",
invalid: "Pick a score from 1 to 5.",
failed: "We couldn't send it. Try again in a moment.",
};
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];
const route = useRoute();
const { loggedIn } = useUserSession();
const screen = computed(() => String(route.name ?? "unknown"));
const outcome = String(route.query.feedback ?? "");
const status = ref<"idle" | "sending" | "sent">(outcome === "sent" ? "sent" : "idle");
const error = ref(MESSAGES[outcome] ?? "");
async function send(event: SubmitEvent) {
const body = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));
status.value = "sending";
error.value = "";
try {
await $fetch("/api/feedback", { method: "POST", body });
status.value = "sent";
} catch (failure) {
error.value = (failure as { data?: { message?: string } }).data?.message ?? "We couldn't send it. Try again in a moment.";
status.value = "idle";
}
}
</script>
<template>
<template v-if="loggedIn">
<p v-if="status === 'sent'" role="status">Thanks. We read every one.</p>
<form v-else method="post" action="/api/feedback" :aria-busy="status === 'sending'" @submit.prevent="send">
<fieldset>
<legend>How is this screen working for you?</legend>
<label v-for="(label, index) in SCORES" :key="label">
<input type="radio" name="rating" :value="index + 1" required /> {{ label }}
</label>
</fieldset>
<label for="feedback-comment">What would make it better?</label>
<textarea id="feedback-comment" name="comment" rows="4" maxlength="4000" />
<input type="hidden" name="screen" :value="screen" />
<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>
</template>methodetactionsont de vrais attributs : avant l'hydratation, le navigateur envoie donc le formulaire à la route. Une fois Vue lancé,@submit.preventprend le relais.- Le corps JSON indique à la route qu'elle peut répondre en JSON. Le corps vient de
FormData: le honeypot etscreensont donc transmis sans code supplémentaire. - Quand un
$fetchéchoue, ledata.messagede l'erreur est le message decreateErrordans la route. - Après un envoi classique,
?feedback=dans l'URL définit l'état initial : le remerciement ou l'erreur s'affiche donc sur la page rechargée. useUserSessionmasque le formulaire aux visiteurs déconnectés. C'est un confort, pas une vérification ; c'est la route qui vérifie.
Placez <FeedbackForm /> dans n'importe quelle page de la partie connectée de votre application, comme app/pages/projects/[id]/settings.vue. Nuxt l'importe automatiquement.
Honeypot, vérification humaine et limites
L'input _gotcha arrive à Ikiform avec le reste des champs, et un honeypot rempli est stocké comme spam. La vérification humaine d'Ikiform est ignorée pour les envois avec clé secrète, et ici c'est la session qui joue ce rôle : la route refuse toute personne non connectée. Il reste le cas d'un compte connecté qui envoie en boucle, qu'Ikiform ne peut compter que par l'adresse de votre 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.
Choix de rendu
Le formulaire a besoin du rendu serveur pour son résultat sans JavaScript. Sur une route avec ssr: false dans routeRules, la page reste vide jusqu'à l'exécution de JavaScript : l'envoi classique n'a donc jamais lieu et ?feedback= n'est lu que dans le navigateur. Ce n'est pas grave, cela supprime seulement la solution de repli. Ne placez pas le formulaire sur une route prerender: true : le HTML est construit une seule fois, donc useUserSession ne voit personne et le formulaire manque jusqu'à l'hydratation.
Déployer
| Hébergeur | Que faire |
|---|---|
| Vercel | Nitro détecte Vercel, aucun preset n'est donc nécessaire. Ajoutez NUXT_IKIFORM_SECRET_KEY et NUXT_SESSION_PASSWORD sous Environment Variables et redéployez. |
| Netlify | Détecté aussi. Ajoutez les deux variables dans les réglages d'environnement du site avec le scope Functions ; la route serveur s'exécute comme une fonction. |
| Cloudflare | Buildez avec le preset cloudflare_module pour Workers, ou connectez le dépôt à Pages, qui est détecté. Stockez les deux valeurs comme secrets, et mettez-les dans .dev.vars pour wrangler dev. |
Sur Cloudflare, les valeurs d'environnement n'existent que pendant l'exécution d'une requête. La route lit useRuntimeConfig(event) dans le handler, et c'est bien là qu'il faut le faire.
Tester gratuitement sur localhost
Sans offre, un espace de travail accepte 50 soumissions de test par mois depuis localhost, d'après l'en-tête Origin. Un fetch depuis une route Nitro n'a pas de navigateur derrière lui et n'envoie pas d'Origin sauf si vous en définissez un. C'est pourquoi la route transmet own.origin : sous nuxt dev, c'est http://localhost:3000, qui compte comme un test, et en production c'est votre domaine. Sans cela, chaque test local revient en 402 plan_required.
Vérifiez que tout fonctionne
- Lancez
nuxt dev, connectez-vous, ouvrez la page de réglages d'un projet et envoyez une note de 2 avec un commentaire. - Dans Submissions, vérifiez que
user_idetemailcorrespondent à votre compte, quescreenvautprojects-id-settingset quepagecontient le vrai chemin. - Désactivez JavaScript et envoyez à nouveau. La page se recharge avec la ligne de remerciement et
?feedback=sentdans l'adresse. - Envoyez vers
/api/feedbackaveccurlet sansOrigin. La route répond403et rien n'atteint Ikiform. - Effacez le cookie de session et envoyez depuis la page ouverte. Le formulaire affiche le message de connexion.
Pièges
| Problème | Que faire |
|---|---|
403 de votre propre route en production | Un proxy change l'hôte, donc getRequestURL ne correspond plus à l'Origin du navigateur. Faites envoyer X-Forwarded-Host par le proxy, que lit xForwardedHost: true. |
screen vaut unknown sur chaque réponse | Les noms de route avec un suffixe de langue, comme ceux de @nuxtjs/i18n, contiennent des caractères que le motif refuse. Élargissez la regex, ou retirez le suffixe dans le composant. |
| La clé secrète est vide en production | Le nom de la variable doit être NUXT_ suivi de la clé en snake case majuscule, NUXT_IKIFORM_SECRET_KEY, et la clé doit exister dans runtimeConfig. |
402 plan_required sur les tests locaux | L'envoi est parti sans Origin. Gardez l'en-tête Origin: own.origin et ouvrez l'application sur localhost. |
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
Avec nuxt-auth-utils, appelez getUserSession(event) ou requireUserSession(event) dans le handler et lisez session.user. Sans lui, renseignez event.context.user dans un middleware serveur et lisez-le. Ne prenez jamais l'utilisateur dans le corps de la requête.
Oui. Donnez au formulaire une vraie method et une vraie action pointant vers une route serveur, et faites rediriger la route en retour avec un 303 quand la requête n'est pas du JSON. Après l'hydratation, interceptez submit et utilisez $fetch.
Déclarez la clé dans runtimeConfig dans nuxt.config, et Nuxt la remplace à l'exécution par une variable d'environnement nommée NUXT_ suivi de la clé en snake case majuscule. Les clés hors de public restent côté serveur.
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.