# Créer un formulaire de feedback dans l'application avec React Router

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

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

Montez un seul formulaire de feedback dans le layout qui entoure vos routes connectées et envoyez-le avec `useFetcher` vers une resource route. Son action lit l'utilisateur dans votre session cookie, prend la page dans `Referer` et transmet la note à votre backend de formulaires avec une clé secrète. Quand JavaScript n'est pas encore chargé, la même action redirige vers la page au lieu de répondre avec du JSON brut.

L'[onglet React Router du guide principal](https://ikiform.com/fr/guides/feedback-form.md#form) envoie un formulaire de page public via un fetcher. Ici, le formulaire vit dans votre application, derrière une connexion, et c'est l'action qui décide qui l'a envoyé. Pour les clés, les domaines autorisés et les bases, voyez le [guide des frameworks serveur](https://ikiform.com/fr/docs/guides/server.md). Le code cible React Router 8 en mode framework.

## Ce que change un fetcher dans le layout

Un formulaire de feedback dans un layout est sur tous les écrans à la fois, et React Router le garde monté pendant la navigation. C'est ce que vous voulez, et cela soulève quatre questions que le guide principal n'a jamais à traiter :

| Point d'attention | Dans un layout | Comment cette page le gère |
| --- | --- | --- |
| Qui l'a envoyé | Le navigateur pourrait se réclamer de n'importe quel utilisateur | L'action lit la session cookie signée |
| Quel écran | Le formulaire ne sait pas quelle route enfant est affichée | Chaque route se nomme dans `handle`, lu avec `useMatches` |
| État résiduel | Les `data` d'un fetcher survivent à la navigation | Un fetcher par écran grâce à l'option `key` |
| Pas encore de JavaScript | `fetcher.Form` est un vrai formulaire, donc le navigateur l'envoie à la resource route | L'action détecte un envoi de document et redirige en retour |

Schéma : Deux façons pour un envoi de feedback React Router d'atteindre la même resource route

1. L'utilisateur note l'écran dans le formulaire que le layout affiche sur chaque route connectée.
2. Après l'hydratation, le fetcher envoie en arrière-plan. L'action lit la session cookie, la page dans Referer et le nom de l'écran.
3. L'action envoie à Ikiform avec la clé secrète et l'utilisateur joint.
4. Le résultat revient via data() et s'affiche sur place, sans navigation.
5. Avant l'hydratation, le navigateur envoie le formulaire comme un document. L'action voit une navigation et redirige vers la page d'origine avec le résultat dans l'URL.

Une seule action, deux types d'appelants. Seule une navigation du navigateur reçoit une redirection ; les appels du fetcher reçoivent des données.

## Mise en place du projet

Les fichiers concernés, dans un projet créé avec `create-react-router` :

`Arborescence du projet`:

```text
app/
  routes.ts
  sessions.server.ts
  components/
    feedback-form.tsx
  routes/
    api.feedback.ts
    app-layout.tsx
    project-settings.tsx
```

Ajoutez une resource route pour l'action et un layout sans chemin pour les écrans connectés. Un module avec une action et sans export par défaut est une resource route : il n'affiche rien.

`app/routes.ts`:

```ts
import { layout, route, type RouteConfig } from "@react-router/dev/routes";

export default [
  route("api/feedback", "routes/api.feedback.ts"),
  layout("routes/app-layout.tsx", [route("projects/:id/settings", "routes/project-settings.tsx")]),
] satisfies RouteConfig;
```

Créez une clé secrète sous **API keys** avec le scope `submissions:write`. Le serveur lit les deux valeurs depuis `process.env` ; définissez-les là où tourne votre serveur, et ne donnez jamais à la clé un préfixe `VITE_`, qui la placerait dans le bundle client.

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=a-long-random-string
```

Collez la définition ci-dessous dans l'onglet **Fields** du formulaire. La personne remplit deux champs ; l'action fournit le reste, et l'`email` issu de la session fait que **Reply** sur chaque notification lui écrit.

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 un `rating` de `9` ou un champ absent de la définition, et vous verrez le `422` que reçoit votre action.

## La construire

### Lire l'utilisateur dans la session cookie

`createCookieSessionStorage` signe le cookie avec `secrets` : un utilisateur peut le lire mais pas le modifier. Si votre connexion écrit déjà une session, réutilisez son stockage et ajoutez `readUser`.

`app/sessions.server.ts`:

```ts
import { createCookieSessionStorage } from "react-router";

interface SessionData {
  userId: string;
  email: string;
}

export const { getSession, commitSession, destroySession } = createCookieSessionStorage<SessionData>({
  cookie: {
    name: "__session",
    httpOnly: true,
    path: "/",
    sameSite: "lax",
    secrets: [process.env.SESSION_SECRET ?? ""],
    secure: process.env.NODE_ENV === "production",
  },
});

export async function readUser(request: Request) {
  const session = await getSession(request.headers.get("Cookie"));
  const id = session.get("userId");
  const email = session.get("email");
  return id && email ? { id, email } : null;
}
```

Le suffixe `.server` garde le module hors du bundle client ; l'importer depuis un composant fait échouer le build.

### La resource route

`app/routes/api.feedback.ts`:

```ts
import { data, redirect } from "react-router";
import { readUser } from "~/sessions.server";
import type { Route } from "./+types/api.feedback";

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

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

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const origin = new URL(request.url).origin;
  const path = samePage(request.headers.get("Referer"), origin);
  const native = request.headers.get("Sec-Fetch-Mode") === "navigate";

  function reply(status: "sent" | "error", message: string, code = 200) {
    if (native) return redirect(`${path || "/"}?feedback=${status}`);
    return data({ status, message }, { status: code });
  }

  const user = await readUser(request);
  if (!user) return reply("error", "You were signed out. Sign in, then send it again.", 401);

  const rating = Number(form.get("rating"));
  if (!Number.isInteger(rating) || rating < 1 || rating > 5) return reply("error", "Choose a score first.", 400);
  const screen = String(form.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: origin,
      },
      body: JSON.stringify({
        rating,
        comment: String(form.get("comment") ?? "").slice(0, 4000),
        page: path ? origin + path : "",
        screen: SCREEN.test(screen) ? screen : "unknown",
        user_id: user.id,
        email: user.email,
        _gotcha: String(form.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return reply("error", "Feedback is unreachable right now. Try again shortly.", 502);
  }

  const result = await response.json().catch(() => null);
  if (result?.ok) return reply("sent", "Got it. Thank you.");
  return reply("error", result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "That didn't go through.", 502);
}
```

- `samePage` ne garde le chemin que si `Referer` est votre propre origine, et supprime les paramètres d'URL, où les URL d'application portent des id et des tokens.
- `Sec-Fetch-Mode` vaut `navigate` quand le navigateur soumet lui-même le formulaire comme un chargement de page. Les fetchers l'envoient comme une requête d'arrière-plan : ils reçoivent donc `data()`, et tous les autres une redirection.
- Les erreurs utilisent un `data()` 4xx ou 5xx plutôt qu'une exception. Une exception remonterait jusqu'à l'`ErrorBoundary` le plus proche et remplacerait l'écran sur lequel l'utilisateur travaillait.
- `Origin` reçoit l'origine propre de la requête. C'est `http://localhost:5173` avec `react-router dev`, et votre domaine en production.

### Le formulaire

`app/components/feedback-form.tsx`:

```tsx
import { useFetcher, useMatches, useSearchParams } from "react-router";
import type { action } from "~/routes/api.feedback";

const SCORES = ["Blocked", "Painful", "Okay", "Smooth", "Excellent"];

function useScreen() {
  const named = useMatches().flatMap((match) => {
    const screen = (match.handle as { screen?: string } | undefined)?.screen;
    return screen ? [screen] : [];
  });
  return named.at(-1) ?? "unknown";
}

export function FeedbackForm() {
  const screen = useScreen();
  const fetcher = useFetcher<typeof action>({ key: `feedback:${screen}` });
  const [params] = useSearchParams();
  const busy = fetcher.state !== "idle";
  const fallback = params.get("feedback");
  const sent = fetcher.data?.status === "sent" || fallback === "sent";
  const error = fetcher.data?.status === "error" ? fetcher.data.message : fallback === "error" ? "That didn't go through." : "";

  if (sent) return <p role="status">Got it. Thank you.</p>;

  return (
    <fetcher.Form method="post" action="/api/feedback" aria-busy={busy}>
      <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={3} maxLength={4000} />
      <input type="hidden" name="screen" value={screen} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={busy}>{busy ? "Sending" : "Send feedback"}</button>
    </fetcher.Form>
  );
}
```

- `useScreen` parcourt les routes correspondantes et garde le `handle.screen` le plus profond, pour qu'une route imbriquée puisse se nommer plus précisément que son parent.
- La `key` du fetcher inclut l'écran. Après un envoi sur un écran, l'écran suivant affiche un formulaire vierge au lieu du remerciement précédent.
- `fetcher.state` vaut `submitting` puis `loading` pendant que les loaders correspondants se revalident. Les deux comptent comme occupés, donc le bouton reste désactivé jusqu'à l'arrivée de la réponse.
- Sans JavaScript, la redirection ramène sur la page avec `?feedback=sent`, et `useSearchParams` affiche la même ligne de remerciement.

### Le monter une seule fois

`app/routes/app-layout.tsx`:

```tsx
import { Outlet } from "react-router";
import { FeedbackForm } from "~/components/feedback-form";

export default function AppLayout() {
  return (
    <>
      <Outlet />
      <aside aria-label="Feedback">
        <FeedbackForm />
      </aside>
    </>
  );
}
```

`app/routes/project-settings.tsx`:

```tsx
export const handle = { screen: "projects/settings" };

export default function ProjectSettings() {
  return <h1>Project settings</h1>;
}
```

L'écran de réglages de chaque projet remonte comme `projects/settings`, pour que leurs moyennes se cumulent ; le chemin concret avec le vrai id reste dans `page`.

## Honeypot, vérification humaine et limites

L'input `_gotcha` traverse l'action sans être modifié, et un honeypot rempli est stocké comme spam. La vérification humaine d'Ikiform est ignorée pour les envois avec une clé secrète, et la vérification de session prend sa place : un visiteur sans cookie signé ne fait rien envoyer. Il reste à surveiller un compte qui envoie en boucle, et Ikiform compte tous ces envois sur l'IP 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

L'action a besoin d'un serveur. Avec `ssr: false` dans `react-router.config.ts`, le build n'a pas de serveur et les actions serveur ne peuvent pas s'exécuter ; vous reviendriez à une `clientAction` qui envoie depuis le navigateur sans identité fiable. Prérendre les pages marketing ne pose pas de problème, car l'action est une route distincte qui s'exécute toujours à la requête. Gardez `ssr: true` pour la partie connectée de l'application.

## Déployer

| Hébergeur | Que faire |
| --- | --- |
| Vercel | Installez `@vercel/react-router` et ajoutez `vercelPreset()` aux `presets` dans `react-router.config.ts`. Ajoutez les deux variables dans les réglages du projet. |
| Netlify | Ajoutez `@netlify/vite-plugin-react-router` aux plugins Vite. Les actions s'exécutent comme des Netlify Functions ; définissez les deux variables dans les réglages d'environnement du site. |
| Cloudflare | Utilisez le modèle React Router de Cloudflare avec `@cloudflare/vite-plugin`. Lisez les bindings avec `import { env } from "cloudflare:workers"` ; dans React Router 8, le `context` de l'action ne porte plus `cloudflare.env`. Lisez-y la clé, et stockez-la comme secret du Worker, avec `.dev.vars` pour les exécutions locales. |

## Tests gratuits depuis localhost

Ikiform donne à un espace de travail sans offre 50 soumissions de test gratuites par mois depuis `localhost`, d'après l'en-tête `Origin`. Un `fetch` depuis votre action n'a pas d'`Origin` sauf si vous en ajoutez un, et un envoi sans origine est traité comme un envoi serveur en production : `402 plan_required`, retenu jusqu'à ce que vous choisissiez une offre. Transmettre l'origine de la requête fait compter `react-router dev` comme un test local.

## Vérifiez que tout fonctionne

1. Lancez `react-router dev`, connectez-vous, ouvrez les réglages d'un projet, choisissez **Painful** et ajoutez un commentaire.
2. Vérifiez **Submissions** : `screen` vaut `projects/settings`, `page` contient le vrai id sans paramètres d'URL, et `user_id` et `email` correspondent à votre compte.
3. Passez à un autre écran. Le formulaire y est vide, sans le remerciement précédent.
4. Désactivez JavaScript et envoyez à nouveau. La page se recharge avec `?feedback=sent` et la ligne de remerciement.
5. Effacez le cookie de session et envoyez. Le formulaire affiche le message de connexion.

## Pièges

| Problème | Que faire |
| --- | --- |
| Le navigateur affiche du JSON brut après l'envoi | L'envoi est arrivé comme une navigation et l'action a renvoyé `data()`. Vérifiez la branche `Sec-Fetch-Mode`, et que rien devant votre serveur ne supprime cet en-tête. |
| `402 plan_required` pendant les tests en local | L'envoi n'avait pas d'origine locale. Gardez l'en-tête `Origin` dans l'action et ouvrez l'application sur `localhost`. |
| `screen` vaut toujours `unknown` | Aucune route correspondante n'exporte de `handle` avec `screen`, ou la valeur contient des majuscules ou des espaces. Utilisez des noms en minuscules, en forme de chemin. |
| L'error boundary remplace la page quand un envoi échoue | Quelque chose dans l'action a levé une exception. Renvoyez `data()` avec un statut pour chaque échec prévu. |
| `readUser` renvoie null juste après la connexion | `SESSION_SECRET` diffère entre le code qui a écrit le cookie et celui-ci, donc la signature échoue. Utilisez un seul module de stockage pour les deux. |

## Questions

### Un formulaire useFetcher peut-il envoyer à une resource route ?

Oui. Donnez à fetcher.Form une action qui pointe vers la resource route. Renvoyez data() depuis l'action pour que le fetcher reçoive le résultat, et redirigez quand la requête est une navigation du navigateur, pour qu'un envoi avant l'hydratation n'affiche pas de JSON brut.

### Comment obtenir l'utilisateur connecté dans une action React Router ?

Lisez la session depuis l'en-tête Cookie avec la fonction getSession de votre stockage de session, puis lisez-y l'id de l'utilisateur. Ne faites jamais confiance à un id utilisateur envoyé par le formulaire.

### Est-ce que cela fonctionne en mode SPA de React Router ?

Non. Avec ssr à false, il n'y a pas de serveur pour exécuter l'action. Vous pourriez envoyer depuis une clientAction, mais c'est alors le navigateur qui fournit l'identité, et elle n'est pas fiable.

### 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 TanStack Start](https://ikiform.com/fr/guides/feedback-form/tanstack-start.md): 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.
- [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é.
