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.
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 :
| Valeur | Provient de | Pourquoi là |
|---|---|---|
rating, comment | Le formulaire | Seul l'utilisateur les connaît |
user_id, email | Le cookie de session, lu dans l'action | Le navigateur ne peut pas falsifier une session signée |
page | L'en-tête Referer de la requête de l'action | Une server action envoie à la page qui l'affiche, donc l'en-tête contient l'URL complète |
screen | Une prop transmise par la page, comme projects/[id]/settings | Regroupe 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.
- L'utilisateur choisit une note et écrit éventuellement un commentaire. Le formulaire les envoie avec le nom de l'écran.
- La server action s'exécute comme un POST vers la page du formulaire, avec le cookie de session et la page dans Referer.
- getUser() lit et vérifie le cookie de session. Sans session valide, rien n'est envoyé.
- 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.
- La réponse d'Ikiform devient l'état de l'action : la ligne de remerciement, ou l'erreur avec le commentaire toujours saisi.
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 :
app/
(app)/
projects/[id]/settings/page.tsx
feedback/
actions.ts
feedback-form.tsx
lib/
user.ts
.env.localCré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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=the-secret-your-auth-already-usesDans 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é.
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.
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
"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.
pageFromne garde que l'origine et le chemin deReferer. 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.screendoit correspondre à un court motif en forme de chemin. Tout le reste est stocké commeunknown, pour qu'un envoi forgé ne puisse pas remplir la colonne de déchets.Origintransmet l'origine du navigateur. En production, c'est votre domaine ; sur votre machine, c'estlocalhost, 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
"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é. pendingdeuseActionStatedé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.
BrokenetGreaten disent plus que1et5, et la valeur stockée reste un nombre.
Le placer sur un écran
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ébergeur | Que faire |
|---|---|
| Vercel | Ajoutez 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. |
| Netlify | Netlify 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. |
| Cloudflare | Cloudflare 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
- Lancez
next dev, connectez-vous, ouvrez les réglages d'un projet et envoyez une note de 2 avec un commentaire. - Ouvrez Submissions. La réponse a
user_id,email,screenàprojects/[id]/settings, etpageavec le vrai id du projet, sans paramètres d'URL. - Déconnectez-vous dans un autre onglet, puis envoyez à nouveau. Le formulaire affiche le message de connexion, avec votre commentaire toujours dans le champ.
- Désactivez JavaScript et envoyez encore une fois. La page se recharge avec la ligne de remerciement.
- Répondez à l'e-mail de notification. La réponse part à l'adresse de l'utilisateur connecté.
Pièges
| Problème | Que faire |
|---|---|
402 plan_required à chaque test local | L'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 vide | Une 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éploiement | Un 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'Ikiform | La 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_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
Lisez la session dans la server action, pas dans le navigateur. Appelez-y votre helper de session ou d'authentification et ajoutez l'id et l'e-mail de l'utilisateur à l'envoi. Tout ce que le navigateur envoie sur l'identité peut être modifié par l'utilisateur.
Une server action est un POST vers la page qui affiche le formulaire, donc son en-tête Referer contient l'URL de cette page. Gardez l'origine et le chemin et supprimez les paramètres d'URL.
Pas celui-ci. Le formulaire ne lit ni cookies ni en-têtes pendant le rendu. Seule l'action lit la session, et elle s'exécute à chaque envoi.
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.