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

Publié le 2026-10-25 · https://ikiform.com/fr/guides/feedback-form/nuxt

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](https://ikiform.com/fr/guides/feedback-form.md#form) 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](https://ikiform.com/fr/docs/guides/nuxt.md).

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

Schéma : Comment une seule route serveur Nuxt gère le feedback avec et sans JavaScript

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`:

```text
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`:

```ts
export default defineNuxtConfig({
  modules: ["nuxt-auth-utils"],
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
```

`.env`:

```bash
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`:

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

Formulaire : In-app feedback

- **Comment cet écran fonctionne-t-il pour vous ?** (`rating`, rating, obligatoire)
- **Qu'est-ce qui l'améliorerait ?** (`comment`, textarea)
- **Page** (`page`, url)
- **Écran** (`screen`, text)
- **Utilisateur** (`user_id`, text)
- **E-mail** (`email`, email)

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`:

```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`:

```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](https://ikiform.com/fr/docs/rate-limits.md) 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

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

### Comment obtenir l'utilisateur connecté dans une route serveur Nuxt ?

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.

### Un formulaire Nuxt peut-il fonctionner sans JavaScript ?

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.

### Comment Nuxt lit-il les variables d'environnement NUXT_ ?

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.

### Ikiform est-il gratuit ?

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.

## Autres guides

- [Formulaire de feedback pour un site ou une application](https://ikiform.com/fr/guides/feedback-form.md): 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.
- [Formulaire de feedback SvelteKit](https://ikiform.com/fr/guides/feedback-form/sveltekit.md): 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.
- [Formulaire de feedback Astro](https://ikiform.com/fr/guides/feedback-form/astro.md): Un 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 Next.js](https://ikiform.com/fr/guides/feedback-form/nextjs.md): Un 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 signalement de bug](https://ikiform.com/fr/guides/feedback-form/bug-report.md): Un 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é](https://ikiform.com/fr/guides/feedback-form/feature-request.md): 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é.
