Formulaire de feedback pour un site ou une application9 min de lecture
Créer un formulaire de feedback dans l'application avec TanStack Start
Un formulaire de feedback dans une application TanStack Start : une server function POST avec un middleware de session, l'id de route comme contexte et la page lue dans les en-têtes.
Écrivez une server function POST pour le feedback et attachez-lui un function middleware qui lit la session et place l'utilisateur dans context. La fonction ajoute à la note cet utilisateur, la page tirée du Referer de la requête et l'id de la route correspondante, puis transmet le tout à votre backend de formulaires avec une clé secrète. Montez le formulaire une seule fois dans votre layout route connectée, et il sait de lui-même sur quel écran il se trouve.
L'onglet TanStack Start du guide principal envoie le feedback de page de n'importe qui, avec la page renseignée dans le navigateur. Dans une application avec connexion, vous pouvez faire mieux : le serveur sait qui demande, et le routeur sait quel écran est ouvert. La configuration du compte, les clés et les domaines autorisés sont traités une fois pour toutes dans le guide serveur de la documentation.
Qui fournit quoi
Dans Start, une server function est un endpoint HTTP sous /_serverFn/ que votre composant appelle via un client généré. Tout ce qui est dans son input peut être réécrit par l'appelant. L'input ne porte donc que ce que l'utilisateur a choisi, et tout ce qui touche à l'identité vient d'un middleware exécuté sur le serveur avant le handler.
| Valeur | Source | Confiance |
|---|---|---|
rating, comment | Le FormData du formulaire | Les mots de l'utilisateur |
user_id, email | context.user, renseigné par le middleware de session | Lu dans un cookie scellé côté serveur |
page | getRequestHeader("referer") dans le handler | Le navigateur appelle la fonction depuis la page ouverte, donc l'en-tête contient son URL |
screen | Le routeId de la correspondance la plus profonde, comme /_app/projects/$projectId/settings | Une étiquette de regroupement. Vérifiée par un motif avant d'être stockée |
- Le formulaire recueille la note et le commentaire, ajoute l'id de la route courante et appelle la server function.
- Côté serveur, le middleware lit la session et transmet l'utilisateur comme contexte.
- Le validateur transforme le FormData en objet typé et écarte un nom d'écran mal formé.
- Le handler refuse un utilisateur manquant, puis ajoute l'utilisateur et la page issue de l'en-tête Referer.
- Il envoie à Ikiform avec la clé secrète et renvoie sent, ou une erreur que le formulaire affiche.
Mise en place du projet
L'arborescence suppose un routage par fichiers avec un layout _app sans chemin qui enveloppe chaque écran connecté. session.ts est le helper iron-session du guide d'authentification de Start ; toute bibliothèque de session qui lit un cookie fonctionne de la même façon. findUser dans users.ts et getCurrentUserFn dans server/user.ts sont vos propres recherches d'utilisateur.
src/
components/feedback-form.tsx
lib/
session.ts
user-middleware.ts
users.ts
routes/
_app.tsx
_app/projects/$projectId/settings.tsx
server/
feedback.ts
user.ts
.envStart lit les variables sans préfixe depuis process.env côté serveur uniquement. Tout ce qui a le préfixe VITE_ est inclus dans le bundle du navigateur : la clé ne doit donc jamais l'avoir. Lisez-la dans le handler, pas en haut du module.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=at-least-32-characters-from-your-auth-setupCollez la définition des champs dans l'onglet Fields du formulaire. Le formulaire en affiche deux ; la server function remplit les quatre autres, 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é.
rating: 6 ou un champ absent de la définition : l'aperçu affiche le 422 que recevrait votre server function.La construire
Middleware de session
Un function middleware enveloppe toute server function qui le liste. Ce qui passe dans next({ context }) arrive typé dans le handler. Il transmet null au lieu de lever une exception, pour que le handler puisse répondre avec un message que le formulaire affiche.
import { createMiddleware } from "@tanstack/react-start";
import { getAppSession } from "./session";
import { findUser } from "./users";
export const userMiddleware = createMiddleware({ type: "function" }).server(async ({ next }) => {
const session = await getAppSession();
const user = session.userId ? await findUser(session.userId) : null;
return next({ context: { user: user ? { id: user.id, email: user.email } : null } });
});La server function
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { userMiddleware } from "../lib/user-middleware";
export type FeedbackResult = { status: "sent" } | { status: "error"; message: string };
const ROUTE_ID = /^\/[A-Za-z0-9/$_.-]{0,119}$/;
function pageFrom(referer: string | undefined) {
try {
const url = new URL(referer ?? "");
return url.origin + url.pathname;
} catch {
return "";
}
}
export const sendFeedback = createServerFn({ method: "POST" })
.middleware([userMiddleware])
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const rating = Number(data.get("rating"));
const screen = String(data.get("screen") ?? "");
return {
rating: Number.isInteger(rating) && rating >= 1 && rating <= 5 ? rating : null,
comment: String(data.get("comment") ?? "").trim().slice(0, 4000),
screen: ROUTE_ID.test(screen) ? screen : "unknown",
trap: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data, context }): Promise<FeedbackResult> => {
if (!context.user) return { status: "error", message: "You're signed out. Sign in and send it again." };
if (data.rating === null) return { status: "error", message: "Choose a score first." };
const origin = getRequestHeader("origin");
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 ? { Origin: origin } : {}),
},
body: JSON.stringify({
rating: data.rating,
comment: data.comment,
page: pageFrom(getRequestHeader("referer")),
screen: data.screen,
user_id: context.user.id,
email: context.user.email,
_gotcha: data.trap,
}),
signal: AbortSignal.timeout(8000),
});
} catch {
return { status: "error", message: "Feedback is unreachable right now. Try again shortly." };
}
const result = await response.json().catch(() => null);
if (result?.ok) return { status: "sent" };
return { status: "error", message: result?.error?.message ?? "That didn't go through. Try again." };
});.validatorreçoit leFormDatabrut et renvoie un objet simple. Une note invalide devientnullau lieu d'une exception, qui arriverait au client comme un échec générique.getRequestHeaderlit la requête entrante depuis n'importe où dans le handler, sans faire circuler d'objet requête.Originest transmis depuis l'appel du navigateur. C'est lui qui décide si Ikiform traite l'envoi comme un test local, comme expliqué plus bas.- Seuls un id et un e-mail vont dans le contexte, pas tout l'enregistrement utilisateur. Tout ce que renvoie le handler est sérialisé vers le navigateur.
Le formulaire
import { useMatches } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { type FeedbackResult, sendFeedback } from "../server/feedback";
const SCORES = ["Badly", "Poorly", "Okay", "Well", "Very well"];
export function FeedbackForm() {
const send = useServerFn(sendFeedback);
const screen = useMatches({ select: (matches) => matches.at(-1)?.routeId ?? "unknown" });
const [result, setResult] = useState<FeedbackResult | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
data.set("screen", screen);
setPending(true);
try {
setResult(await send({ data }));
} catch {
setResult({ status: "error", message: "We lost the connection. Try again." });
} finally {
setPending(false);
}
}
if (result?.status === "sent") return <p role="status">Thanks. We read every one.</p>;
return (
<form method="post" onSubmit={onSubmit} 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} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{result?.status === "error" ? <p role="alert">{result.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
</form>
);
}useMatchesavecselectne renvoie que lerouteIdde la dernière correspondance : le composant se réaffiche donc quand l'écran change, pas à chaque mise à jour du routeur.onSubmitbloque l'envoi natif et appelle la fonction. Rien ne réinitialise le formulaire : après une erreur, le commentaire est toujours dans le champ.pendingdésactive le bouton pendant l'appel, ce qui évite un double envoi.
Le monter dans la layout route
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { FeedbackForm } from "../components/feedback-form";
import { getCurrentUserFn } from "../server/user";
export const Route = createFileRoute("/_app")({
beforeLoad: async () => {
const user = await getCurrentUserFn();
if (!user) throw redirect({ to: "/login" });
return { user };
},
component: () => (
<>
<Outlet />
<aside>
<FeedbackForm />
</aside>
</>
),
});beforeLoad tient les utilisateurs déconnectés à l'écart des écrans, mais ce n'est pas ce qui protège le feedback : n'importe qui peut appeler /_serverFn/ directement, c'est pourquoi le middleware vérifie à nouveau. Les id de route incluent les layouts sans chemin, d'où le préfixe /_app dans screen. Retirez-le avant de stocker si vous préférez des noms plus courts.
Honeypot, vérification humaine et limites
L'input _gotcha traverse le validateur sans être modifié, et Ikiform classe un honeypot rempli comme spam. Sa vérification humaine est ignorée pour les envois faits avec une clé secrète, ce qui convient derrière une session : les bots n'en ont pas. Il reste le cas d'un compte connecté qui envoie en boucle, et Ikiform compte tous ces envois sur 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.
SSR, mode SPA et prérendu
Le formulaire fonctionne de la même façon en SSR complet ou avec spa: { enabled: true } dans les options de tanstackStart(). En mode SPA, seul le shell racine est prérendu et les écrans s'affichent dans le navigateur, mais les server functions ont toujours besoin d'un serveur actif : si un hébergeur réécrit chaque chemin vers le shell, ajoutez /_serverFn/* aux chemins qu'il laisse passer. Ne prérendez pas les écrans connectés ; il n'y a pas d'utilisateur au moment du build.
La documentation de Start mentionne aussi une propriété .url pour appeler une server function depuis un simple formulaire HTML. Cette page ne l'utilise pas. Un écran connecté est hydraté avant que quiconque ne tape un commentaire, et un envoi sans script exigerait que la fonction réponde par une redirection plutôt que par des données. Considérez que le formulaire a besoin de JavaScript.
Déployer
| Hébergeur | Configuration |
|---|---|
| Vercel | Ajoutez nitro() de nitro/vite aux plugins Vite, déployez, et ajoutez IKIFORM_SECRET_KEY aux variables d'environnement du projet. |
| Netlify | Ajoutez netlify() de @netlify/vite-plugin-tanstack-start et définissez la clé dans les variables d'environnement du site. |
| Cloudflare | Ajoutez cloudflare({ viteEnvironment: { name: "ssr" } }) de @cloudflare/vite-plugin, activez nodejs_compat et stockez la clé avec wrangler secret put. Avec une date de compatibilité au 2025-04-01 ou plus récente, ce flag remplit process.env avec vos secrets. |
Tests gratuits depuis localhost
Sans offre, un espace de travail accepte 50 soumissions de test par mois quand l'en-tête Origin indique localhost. Le fetch de votre serveur n'a pas d'origine : un envoi sans l'en-tête transmis reçoit 402 plan_required et est retenu. Avec lui, vite dev sur http://localhost:3000 transmet l'origine du navigateur et chaque réponse compte comme un test. En production, la même ligne envoie le domaine de votre application.
Vérifiez que tout fonctionne
- Lancez
vite dev, connectez-vous, ouvrez les réglages d'un projet et envoyez un 2 avec un commentaire. - Dans Submissions, la réponse a
user_id,email,screenavec l'id de la route, etpageavec le vrai id du projet. - Déconnectez-vous dans un autre onglet et envoyez à nouveau. Le formulaire affiche le message de déconnexion et garde le commentaire.
- Appelez la fonction depuis la console du navigateur avec un id utilisateur forgé dans les données du formulaire. Le
user_idstocké reste celui de la session. - Répondez à l'e-mail de notification. La réponse part à l'utilisateur connecté.
Pièges
| Problème | Que faire |
|---|---|
Les server functions ont perdu leur protection CSRF après l'ajout de src/start.ts | Sans src/start.ts, Start ajoute lui-même son middleware CSRF pour les server functions. Dès que vous en définissez un, enregistrez-y vous-même createCsrfMiddleware, filtré sur les server functions. |
402 plan_required sur les tests locaux | L'en-tête Origin n'est pas arrivé à Ikiform. Vérifiez la ligne de transmission, et ouvrez l'application sur localhost plutôt que sur une adresse du réseau local. |
IKIFORM_SECRET_KEY est undefined sur Cloudflare | Les Workers ne remplissent process.env qu'avec nodejs_compat activé et une date de compatibilité au 2025-04-01 ou plus récente. Vérifiez les deux dans wrangler.jsonc, ou lisez la clé depuis env dans cloudflare:workers. |
screen vaut toujours unknown | L'id de route n'a pas passé le motif, souvent à cause de caractères comme { des paramètres optionnels. Élargissez le motif pour qu'il corresponde à vos routes. |
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
Écrivez un function middleware qui lit la session et appelle next avec l'utilisateur dans le contexte, puis listez-le dans le middleware de la server function. Le handler lit context.user, typé, côté serveur.
Oui. getRequestHeader et getRequest de @tanstack/react-start/server lisent la requête entrante dans le handler : vous pouvez donc prendre la page dans Referer sans l'envoyer depuis le navigateur.
Oui, tant qu'un serveur exécute vos server functions et que votre hébergeur ne réécrit pas les chemins /_serverFn/ vers le shell SPA.
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.