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

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

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

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

Schéma : Comment une server function TanStack Start ajoute l'utilisateur et la page à une réponse de feedback

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

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

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

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)

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

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

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

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

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

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

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

### Comment obtenir l'utilisateur courant dans une server function TanStack Start ?

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

### Une server function TanStack Start peut-elle lire les en-têtes de la requête ?

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.

### Le formulaire de feedback fonctionne-t-il en mode SPA de TanStack Start ?

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.

### 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 React Router](https://ikiform.com/fr/guides/feedback-form/react-router.md): Un formulaire de feedback React Router 8 : l'action d'une resource route lit la session cookie, un fetcher envoie depuis le layout et un envoi sans JS redirige en retour.
- [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.
- [Widget de feedback React](https://ikiform.com/fr/guides/feedback-form/react-feedback-widget.md): Un bouton de feedback flottant pour une application React qui ouvre un petit panneau, envoie l'écran, la version et le compte avec chaque réponse, et ne perd jamais un brouillon.
- [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é.
