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

Preet SutharFondateur, Ikiform

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 :

ChampRenseigné parComment
rating, commentLa personne elle-mêmeDes boutons radio et une zone de texte
user_id, emailLa server routegetUserSession(event) lit le cookie de session scellé
pageLa server routeL'en-tête Referer, que le navigateur envoie avec le chemin complet sur les envois de même origine
screenLe composant, vérifié par la routeuseRoute().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.

Comment une seule route serveur Nuxt gère le feedback avec et sans JavaScriptAvec JavaScriptSans JavaScriptFeedbackForm.vue$fetch,corpsJSONfeedback.post.tsorigine,session,ZodIkiformclésecrète,stocke{ sent:true }le merciremplaceleformulaireenvoideformulairesimplecorpsurlencodedfeedback.post.tsmêmesvérificationsIkiformmêmeenvoi303 enretour?feedback=sentPOST+ utilisateur, page200POST+ utilisateur, pageredirectionComment une seule route serveur Nuxt gère le feedback avec et sans JavaScriptAvec JavaScriptSans JavaScriptFeedbackForm.vue$fetch, corps JSONfeedback.post.tsorigine, session, ZodIkiformclé secrète, stocke{ sent: true }le merci remplace le formulaireenvoi de formulairesimplecorps urlencodedfeedback.post.tsmêmes vérificationsIkiformmême envoi303 en retour?feedback=sentPOST+ utilisateur, page200POST+ utilisateur, pageredirection
  1. Après l'hydratation, le composant bloque l'envoi natif et envoie les champs en JSON avec $fetch.
  2. La route serveur vérifie l'Origin, lit l'utilisateur dans la session, valide la note et ajoute la page issue de Referer.
  3. Elle transmet la réponse à Ikiform avec la clé secrète tirée de runtimeConfig.
  4. Une requête JSON reçoit { sent: true } ou une erreur avec un message, et le formulaire se met à jour sur place.
  5. Avant l'hydratation, le navigateur envoie lui-même le formulaire. La route effectue les mêmes vérifications et le même envoi.
  6. 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.
La route distingue les deux grâce au content type de la requête. Les deux chemins stockent exactement la même réponse.

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.

Arborescence du projet
app/
  components/FeedbackForm.vue
  pages/projects/[id]/settings.vue
server/
  api/feedback.post.ts
shared/
  types/auth.d.ts
nuxt.config.ts
.env

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

nuxt.config.ts
export default defineNuxtConfig({
  modules: ["nuxt-auth-utils"],
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
.env
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_SESSION_PASSWORD=at-least-32-characters-long-random-string

Cré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é :

shared/types/auth.d.ts
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é.

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

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

L'aperçu exécute le validateur d'Ikiform : une note de 6 affiche donc le 422 que recevrait la route.

La construire

La server route

server/api/feedback.post.ts
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.ts fait que le fichier ne répond qu'à POST.
  • La vérification d'Origin passe avant tout le reste. readBody, qu'utilise readValidatedBody, 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.
  • getUserSession ne lève pas d'exception. La route choisit elle-même la réponse : un 401 pour $fetch, une redirection pour un envoi classique. requireUserSession enverrait une page 401 à un utilisateur sans JavaScript.
  • z.coerce.number() transforme le "4" urlencoded en 4. .catch("unknown") stocke un screen mal formé comme unknown au lieu de refuser toute la réponse.
  • back ne garde que le chemin de Referer, pour qu'une redirection ne puisse jamais quitter votre site.

Le composant

app/components/FeedbackForm.vue
<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>
  • method et action sont de vrais attributs : avant l'hydratation, le navigateur envoie donc le formulaire à la route. Une fois Vue lancé, @submit.prevent prend le relais.
  • Le corps JSON indique à la route qu'elle peut répondre en JSON. Le corps vient de FormData : le honeypot et screen sont donc transmis sans code supplémentaire.
  • Quand un $fetch échoue, le data.message de l'erreur est le message de createError dans 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.
  • useUserSession masque 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ébergeurQue faire
VercelNitro 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.
NetlifyDé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.
CloudflareBuildez 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

  1. Lancez nuxt dev, connectez-vous, ouvrez la page de réglages d'un projet et envoyez une note de 2 avec un commentaire.
  2. Dans Submissions, vérifiez que user_id et email correspondent à votre compte, que screen vaut projects-id-settings et que page contient le vrai chemin.
  3. Désactivez JavaScript et envoyez à nouveau. La page se recharge avec la ligne de remerciement et ?feedback=sent dans l'adresse.
  4. Envoyez vers /api/feedback avec curl et sans Origin. La route répond 403 et rien n'atteint Ikiform.
  5. Effacez le cookie de session et envoyez depuis la page ouverte. Le formulaire affiche le message de connexion.

Pièges

ProblèmeQue faire
403 de votre propre route en productionUn 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éponseLes 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 productionLe 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 locauxL'envoi est parti sans Origin. Gardez l'en-tête Origin: own.origin et ouvrez l'application sur localhost.
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.