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 Next.js

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.

Preet SutharFondateur, Ikiform

Placez un petit formulaire client avec une note de 1 à 5 sur les écrans où vous voulez du feedback, et reliez-le à une server action. L'action lit l'utilisateur connecté dans votre cookie de session, prend la page dans l'en-tête Referer de la requête et transmet le tout à votre backend de formulaires avec une clé secrète. Le navigateur n'envoie jamais l'identité de l'utilisateur : personne ne peut donc envoyer un feedback au nom de quelqu'un d'autre.

L'onglet Next.js du guide principal est un formulaire de feedback de page pour tout visiteur. Cette page concerne l'intérieur de votre application, là où les gens sont connectés. Vous savez déjà qui écrit et sur quel écran : le formulaire pose donc deux questions et le serveur complète le reste. La configuration du produit, les clés et les domaines autorisés sont dans le guide Next.js de la documentation.

Ce qui change quand l'utilisateur est connecté

Un formulaire de feedback public prend ce que le navigateur lui donne. Un formulaire dans l'application ne devrait pas. Si le navigateur envoie user_id, n'importe qui peut le changer dans les outils de développement et envoyer un feedback au nom d'un collègue ou d'un client. Chaque valeur doit venir de la source qui mérite confiance pour elle :

ValeurProvient dePourquoi là
rating, commentLe formulaireSeul l'utilisateur les connaît
user_id, emailLe cookie de session, lu dans l'actionLe navigateur ne peut pas falsifier une session signée
pageL'en-tête Referer de la requête de l'actionUne server action envoie à la page qui l'affiche, donc l'en-tête contient l'URL complète
screenUne prop transmise par la page, comme projects/[id]/settingsRegroupe la page de réglages de chaque projet sous un même nom, pour que les moyennes aient du sens

screen est la seule valeur qui passe par le navigateur. C'est une étiquette de regroupement, pas une permission, et l'action vérifie sa forme avant qu'elle ne soit stockée.

Comment une réponse de feedback Next.js récupère l'utilisateur et la page côté serveurFeedbackFormnote,commentaire,écranserveractionPOSTverslapagecourantegetUser()litlecookiedesessionIkiformclésecrète,stockeétatdel'actionmerci,oul'erreurétatd'erreursereconnecter,commentairegardéFormDatacookies()+ utilisateur, pagerésultatpas de sessionComment une réponse de feedback Next.js récupère l'utilisateur et la page côté serveurFeedbackFormnote, commentaire,écranserver actionPOST vers la pagecourantegetUser()lit le cookiede sessionIkiformclé secrète, stockeétat de l'actionmerci, ou l'erreurétatd'erreursereconnecter,commentairegardéFormDatacookies()+ utilisateur, pagerésultatpas de session
  1. L'utilisateur choisit une note et écrit éventuellement un commentaire. Le formulaire les envoie avec le nom de l'écran.
  2. La server action s'exécute comme un POST vers la page du formulaire, avec le cookie de session et la page dans Referer.
  3. getUser() lit et vérifie le cookie de session. Sans session valide, rien n'est envoyé.
  4. L'action ajoute l'id et l'e-mail de l'utilisateur ainsi que l'adresse de la page, et envoie à Ikiform avec la clé secrète.
  5. La réponse d'Ikiform devient l'état de l'action : la ligne de remerciement, ou l'erreur avec le commentaire toujours saisi.
La session est lue à l'envoi du formulaire, pas au rendu de la page. La page autour du formulaire peut rester statique.

Mise en place du projet

Quatre fichiers, dont un que vous avez probablement déjà. Le layout de l'App Router est le layout habituel :

Arborescence du projet
app/
  (app)/
    projects/[id]/settings/page.tsx
  feedback/
    actions.ts
    feedback-form.tsx
lib/
  user.ts
.env.local

Créez une clé secrète sous API keys avec le scope submissions:write et placez-la dans .env.local. Ne lui donnez jamais de préfixe NEXT_PUBLIC_, qui l'enverrait à tous les navigateurs.

.env.local
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=the-secret-your-auth-already-uses

Dans l'onglet Fields du formulaire, collez cette définition. Les utilisateurs voient les deux premiers champs ; votre serveur renseigne les quatre autres. Comme il y a un champ email, Reply sur la notification écrit directement à 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.

Essayez une note de 0 ou un nom de champ inconnu : l'aperçu répond avec le même 422 que reçoit votre action.

La construire

Lire l'utilisateur à un seul endroit

Next.js recommande une couche d'accès aux données : une seule fonction réservée au serveur qui lit et vérifie la session, utilisée par chaque action. Le cache de React fait qu'au sein d'une même requête, les appels répétés ne lisent le cookie qu'une fois. decrypt est ce que votre code de session a déjà ; avec une bibliothèque d'authentification, renvoyez plutôt son utilisateur ici.

lib/user.ts
import "server-only";

import { cookies } from "next/headers";
import { cache } from "react";
import { decrypt } from "@/lib/session";

export interface User {
  id: string;
  email: string;
}

export const getUser = cache(async (): Promise<User | null> => {
  const token = (await cookies()).get("session")?.value;
  if (!token) return null;
  const session = await decrypt(token);
  return session?.userId ? { id: session.userId, email: session.email } : null;
});

La server action

app/feedback/actions.ts
"use server";

import { headers } from "next/headers";
import { getUser } from "@/lib/user";

export interface FeedbackState {
  status: "idle" | "sent" | "error";
  message: string;
  comment?: string;
}

const SCREEN = /^[a-z0-9/[\]._-]{1,120}$/;

function pageFrom(referer: string | null) {
  try {
    const url = new URL(referer ?? "");
    return url.origin + url.pathname;
  } catch {
    return "";
  }
}

export async function sendFeedback(_previous: FeedbackState, formData: FormData): Promise<FeedbackState> {
  const comment = String(formData.get("comment") ?? "").trim().slice(0, 4000);
  const user = await getUser();
  if (!user) return { status: "error", message: "Your session ended. Sign in again, then send it.", comment };

  const rating = Number(formData.get("rating"));
  if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
    return { status: "error", message: "Pick a score from 1 to 5.", comment };
  }

  const request = await headers();
  const screen = String(formData.get("screen") ?? "");

  let response: Response;
  try {
    response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        Origin: request.get("origin") ?? "",
      },
      body: JSON.stringify({
        rating,
        comment,
        page: pageFrom(request.get("referer")),
        screen: SCREEN.test(screen) ? screen : "unknown",
        user_id: user.id,
        email: user.email,
        _gotcha: String(formData.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return { status: "error", message: "We couldn't send it. Try again in a moment.", comment };
  }

  const result = await response.json().catch(() => null);
  if (result?.ok) return { status: "sent", message: "Thanks. We read every one." };
  return {
    status: "error",
    message: result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "We couldn't send it. Try again.",
    comment,
  };
}
  • La vérification de session passe en premier et ne lève pas d'exception. Un utilisateur dont la session a expiré en plein commentaire reçoit un message et garde son commentaire, pas une page d'erreur.
  • pageFrom ne garde que l'origine et le chemin de Referer. Dans une application, les paramètres d'URL contiennent souvent des id et des tokens que vous ne voulez pas dans une boîte de feedback.
  • screen doit correspondre à un court motif en forme de chemin. Tout le reste est stocké comme unknown, pour qu'un envoi forgé ne puisse pas remplir la colonne de déchets.
  • Origin transmet l'origine du navigateur. En production, c'est votre domaine ; sur votre machine, c'est localhost, ce qui rend les tests locaux gratuits, comme expliqué plus bas.
  • AbortSignal.timeout évite que le bouton tourne indéfiniment quand le réseau est lent.

Le formulaire

app/feedback/feedback-form.tsx
"use client";

import { useActionState } from "react";
import { type FeedbackState, sendFeedback } from "./actions";

const initial: FeedbackState = { status: "idle", message: "" };
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];

export function FeedbackForm({ screen }: { screen: string }) {
  const [state, action, pending] = useActionState(sendFeedback, initial);

  if (state.status === "sent") return <p role="status">{state.message}</p>;

  return (
    <form action={action} aria-busy={pending}>
      <fieldset>
        <legend>How is this screen working for you?</legend>
        {SCORES.map((label, index) => (
          <label key={label}>
            <input type="radio" name="rating" value={index + 1} required /> {label}
          </label>
        ))}
      </fieldset>
      <label htmlFor="feedback-comment">What would make it better?</label>
      <textarea id="feedback-comment" name="comment" rows={4} maxLength={4000} defaultValue={state.comment} />
      <input type="hidden" name="screen" value={screen} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.status === "error" ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
    </form>
  );
}
  • L'action va directement dans action. Next.js l'affiche comme un vrai envoi de formulaire, donc un utilisateur qui clique avant le chargement du JavaScript voit quand même son feedback envoyé.
  • pending de useActionState désactive le bouton pendant l'exécution de l'action, ce qui évite les doubles envois.
  • React réinitialise le formulaire après une action. defaultValue={state.comment} remet le commentaire en cas d'erreur, pour que personne ne le tape deux fois.
  • Les libellés nomment les notes. Broken et Great en disent plus que 1 et 5, et la valeur stockée reste un nombre.

Le placer sur un écran

app/(app)/projects/[id]/settings/page.tsx
import { FeedbackForm } from "@/app/feedback/feedback-form";

export default function ProjectSettingsPage() {
  return (
    <main>
      <h1>Project settings</h1>
      <aside>
        <FeedbackForm screen="projects/[id]/settings" />
      </aside>
    </main>
  );
}

Passez le chemin du dossier de la route comme screen. La page de réglages de chaque projet partage alors un même nom, et l'adresse complète avec le vrai id reste dans page.

Honeypot, vérification humaine et limites

L'input masqué _gotcha est transmis tel quel. Ikiform stocke un honeypot rempli comme spam. La vérification humaine ne s'exécute pas, car un envoi avec une clé secrète la contourne, et ça convient ici : l'action refuse toute personne sans session, ce qu'un bot n'a pas. Le risque restant est un compte connecté qui envoie en boucle. La limite de débit d'Ikiform ne voit que l'adresse de votre serveur : choisissez donc un préréglage adapté.

Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore Allowed domains et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la limite de débit Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.

Rendu et cache

Le formulaire ne lit rien au rendu. Il n'appelle ni cookies() ni headers(), il ne rend donc pas la page dynamique, et avec Cache Components activé il peut rester dans le shell statique sans frontière <Suspense>. Seule l'action touche à la requête, et les actions s'exécutent toujours à chaque requête. Si la page lit déjà la session pour d'autres raisons, rien ne change.

Déployer

HébergeurQue faire
VercelAjoutez IKIFORM_SECRET_KEY dans les Environment Variables du projet pour Production et Preview. Les actions s'exécutent comme des fonctions, sans configuration supplémentaire.
NetlifyNetlify build Next.js avec sa propre intégration. Ajoutez la clé dans les variables d'environnement du site avec le scope Functions, puis redéployez.
CloudflareCloudflare exécute Next.js sur Workers via son intégration OpenNext. Stockez la clé comme secret du Worker, et mettez-la dans .dev.vars pour les aperçus locaux du build Worker.

Derrière un proxy ou un CDN qui change l'hôte, Next.js rejette les actions dont l'Origin ne correspond pas. Listez ce domaine dans experimental.serverActions.allowedOrigins de next.config.ts.

Tester gratuitement sur localhost

Un espace de travail sans offre reçoit 50 soumissions de test par mois, mais seulement depuis localhost, qu'Ikiform lit dans l'en-tête Origin. Un fetch côté serveur n'envoie pas d'Origin de lui-même : sans la ligne qui le transmet, chaque test local reviendrait en 402 plan_required et serait retenu. Avec elle, next dev sur http://localhost:3000 compte comme un test local, et les envois de production portent votre vrai domaine.

Vérifiez que tout fonctionne

  1. Lancez next dev, connectez-vous, ouvrez les réglages d'un projet et envoyez une note de 2 avec un commentaire.
  2. Ouvrez Submissions. La réponse a user_id, email, screen à projects/[id]/settings, et page avec le vrai id du projet, sans paramètres d'URL.
  3. Déconnectez-vous dans un autre onglet, puis envoyez à nouveau. Le formulaire affiche le message de connexion, avec votre commentaire toujours dans le champ.
  4. Désactivez JavaScript et envoyez encore une fois. La page se recharge avec la ligne de remerciement.
  5. Répondez à l'e-mail de notification. La réponse part à l'adresse de l'utilisateur connecté.

Pièges

ProblèmeQue faire
402 plan_required à chaque test localL'envoi est arrivé à Ikiform sans origine localhost. Vérifiez la ligne Origin dans l'action, et ouvrez l'application sur localhost, pas sur l'adresse réseau de votre machine.
page est videUne Referrer-Policy stricte comme no-referrer supprime l'en-tête, même sur les requêtes de même origine. Utilisez strict-origin-when-cross-origin, la valeur par défaut.
Failed to find Server Action, juste après un déploiementUn onglet exécute encore le build précédent, dont l'id d'action n'existe plus. Demandez à l'utilisateur de recharger, et gardez la même NEXT_SERVER_ACTIONS_ENCRYPTION_KEY sur toutes les instances si vous auto-hébergez.
403 forbidden de la part d'IkiformLa clé n'a pas submissions:write, appartient à un autre espace de travail ou a été révoquée. Créez-en une nouvelle.
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.