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

Preet SutharFondateur, Ikiform

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

ValeurSourceConfiance
rating, commentLe FormData du formulaireLes mots de l'utilisateur
user_id, emailcontext.user, renseigné par le middleware de sessionLu dans un cookie scellé côté serveur
pagegetRequestHeader("referer") dans le handlerLe navigateur appelle la fonction depuis la page ouverte, donc l'en-tête contient son URL
screenLe routeId de la correspondance la plus profonde, comme /_app/projects/$projectId/settingsUne étiquette de regroupement. Vérifiée par un motif avant d'être stockée
Comment une server function TanStack Start ajoute l'utilisateur et la page à une réponse de feedbackFeedbackFormFormData+routeIduserMiddlewarelitlecookiedesessionvalidateurnote,commentaire,formedel'écranhandlerajouteutilisateuretpageRefererIkiformclésecrète,stockerésultatd'erreurleformulairereste,commentairegardé/_serverFncontext.userdonnéesJSONpas d'utilisateurComment une server function TanStack Start ajoute l'utilisateur et la page à une réponse de feedbackFeedbackFormFormData + routeIduserMiddlewarelit le cookie desessionvalidateurnote, commentaire,forme de l'écranhandlerajouteutilisateur etpage RefererIkiformclé secrète, stockerésultatd'erreurleformulairereste,commentairegardé/_serverFncontext.userdonnéesJSONpas d'utilisateur
  1. Le formulaire recueille la note et le commentaire, ajoute l'id de la route courante et appelle la server function.
  2. Côté serveur, le middleware lit la session et transmet l'utilisateur comme contexte.
  3. Le validateur transforme le FormData en objet typé et écarte un nom d'écran mal formé.
  4. Le handler refuse un utilisateur manquant, puis ajoute l'utilisateur et la page issue de l'en-tête Referer.
  5. Il envoie à Ikiform avec la clé secrète et renvoie sent, ou une erreur que le formulaire affiche.
L'utilisateur vient du cookie, côté serveur. Rien dans les données du formulaire ne peut changer l'auteur d'une réponse.

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.

Arborescence du projet
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
.env

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

.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=at-least-32-characters-from-your-auth-setup

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

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

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

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

src/lib/user-middleware.ts
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

src/server/feedback.ts
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." };
  });
  • .validator reçoit le FormData brut et renvoie un objet simple. Une note invalide devient null au lieu d'une exception, qui arriverait au client comme un échec générique.
  • getRequestHeader lit la requête entrante depuis n'importe où dans le handler, sans faire circuler d'objet requête.
  • Origin est 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

src/components/feedback-form.tsx
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>
  );
}
  • useMatches avec select ne renvoie que le routeId de la dernière correspondance : le composant se réaffiche donc quand l'écran change, pas à chaque mise à jour du routeur.
  • onSubmit bloque l'envoi natif et appelle la fonction. Rien ne réinitialise le formulaire : après une erreur, le commentaire est toujours dans le champ.
  • pending désactive le bouton pendant l'appel, ce qui évite un double envoi.

Le monter dans la layout route

src/routes/_app.tsx
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ébergeurConfiguration
VercelAjoutez nitro() de nitro/vite aux plugins Vite, déployez, et ajoutez IKIFORM_SECRET_KEY aux variables d'environnement du projet.
NetlifyAjoutez netlify() de @netlify/vite-plugin-tanstack-start et définissez la clé dans les variables d'environnement du site.
CloudflareAjoutez 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

  1. Lancez vite dev, connectez-vous, ouvrez les réglages d'un projet et envoyez un 2 avec un commentaire.
  2. Dans Submissions, la réponse a user_id, email, screen avec l'id de la route, et page avec le vrai id du projet.
  3. Déconnectez-vous dans un autre onglet et envoyez à nouveau. Le formulaire affiche le message de déconnexion et garde le commentaire.
  4. Appelez la fonction depuis la console du navigateur avec un id utilisateur forgé dans les données du formulaire. Le user_id stocké reste celui de la session.
  5. Répondez à l'e-mail de notification. La réponse part à l'utilisateur connecté.

Pièges

ProblèmeQue faire
Les server functions ont perdu leur protection CSRF après l'ajout de src/start.tsSans 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 locauxL'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 CloudflareLes 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 unknownL'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_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.