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

> Un formulaire de feedback dans une application Astro : une action qui prend l'utilisateur dans les locals du middleware, la page dans la requête et l'écran dans le motif de route.

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

Écrivez une action Astro avec `accept: "form"` qui valide la note avec Zod, lit l'utilisateur connecté dans `context.locals` et transmet la réponse à votre backend de formulaires avec un secret issu de `astro:env`. Affichez le formulaire avec son `action` réglée sur l'action, pour qu'il envoie sans JavaScript, et ajoutez un court script qui appelle la même action sur place quand JavaScript est disponible. La page concernée vient du `Referer` de la requête, et `Astro.routePattern` nomme l'écran.

L'[onglet Astro du guide principal](https://ikiform.com/fr/guides/feedback-form.md#form) demande à tout visiteur si une page lui a été utile. Cette page place le feedback dans une application Astro avec des utilisateurs connectés, où c'est le serveur qui doit décider qui a écrit, et où le formulaire ne demande qu'une note et un commentaire. Pour les clés et la configuration du formulaire, le [guide Astro de la documentation](https://ikiform.com/fr/docs/guides/astro.md) donne les détails.

## Qui remplit quoi

Les actions Astro sont des endpoints publics, accessibles à une URL qui porte le nom de l'action. N'importe qui peut appeler `sendFeedback` avec un corps forgé, donc l'action ne peut pas croire un id utilisateur qu'on lui donne. Répartissez les valeurs selon qui mérite confiance pour les fournir :

| Valeur | Renseigné par | Comment |
| --- | --- | --- |
| `rating`, `comment` | La personne elle-même | Validé par le schéma Zod de l'action |
| `user_id`, `email` | Middleware | `context.locals.user`, depuis le cookie de session à chaque requête |
| `page` | La requête | L'en-tête `Referer`, qui est la page où se trouve le formulaire |
| `screen` | Le rendu de la page | `Astro.routePattern`, comme `/app/projects/[id]/settings`, dans un input masqué |

`routePattern` est ce qui rend Astro agréable ici. Il nomme le fichier qui a rendu la page : l'écran de réglages de chaque projet arrive donc sous un même libellé, tandis que `page` garde l'adresse réelle. Il passe par le navigateur, donc le schéma vérifie sa forme et remplace toute bizarrerie par `unknown`.

Schéma : Deux façons pour un formulaire de feedback Astro d'atteindre la même action

1. Sans JavaScript, le navigateur envoie le formulaire à la page courante, avec le nom de l'action dans l'URL.
2. Le middleware s'exécute en premier à chaque requête et place l'utilisateur connecté dans context.locals.
3. L'action valide la note et l'écran, ajoute l'utilisateur et la page, et envoie à Ikiform avec le secret.
4. Astro affiche à nouveau la page, et Astro.getActionResult() montre la ligne de remerciement ou l'erreur.
5. Avec JavaScript, le script appelle la même action via fetch. Le middleware et le handler s'exécutent exactement comme avant.
6. Le script remplace le formulaire par la ligne de remerciement, ou écrit l'erreur en dessous, sans rechargement.

Un seul handler, deux transports. Les deux requêtes viennent de la page, elles portent donc toutes deux le cookie de session et la page dans Referer.

## Mise en place du projet

`Arborescence du projet`:

```text
src/
  actions/index.ts
  components/FeedbackForm.astro
  middleware.ts
  env.d.ts
  pages/app/projects/[id]/settings.astro
astro.config.mjs
.env
```

Déclarez le secret dans le schéma `astro:env`. Astro refuse alors de démarrer sans lui, type l'import et le garde hors du bundle client. N'importe quel adaptateur serveur fonctionne ; l'exemple utilise Node.

`astro.config.mjs`:

```js
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});
```

Mettez `IKIFORM_SECRET_KEY=ik_sk_...` dans `.env` pour le développement local. Créez la clé sous **API keys** avec le scope `submissions:write`. Collez ensuite cette définition dans l'onglet **Fields** du formulaire :

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)

Le schéma de l'action reprend ces limites, donc la plupart des saisies invalides sont refusées avant de coûter une requête.

## La construire

### Placer l'utilisateur dans locals

Le middleware s'exécute avant chaque page et chaque action. Lisez-y votre session une fois, avec ce que fournit votre code d'authentification, et chaque handler reçoit le résultat. `readSession` représente ce code. Les sessions natives d'Astro fonctionnent aussi : lisez plutôt `context.session` dans l'action.

`src/middleware.ts`:

```ts
import { defineMiddleware } from "astro:middleware";
import { readSession } from "./lib/auth";

export const onRequest = defineMiddleware(async (context, next) => {
  context.locals.user = await readSession(context.cookies.get("session")?.value);
  return next();
});
```

`src/env.d.ts`:

```ts
declare namespace App {
  interface Locals {
    user: { id: string; email: string } | null;
  }
}
```

### L'action du formulaire

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";

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

export const server = {
  sendFeedback: defineAction({
    accept: "form",
    input: z.object({
      rating: z.coerce.number().int().min(1, "Pick a score from 1 to 5.").max(5, "Pick a score from 1 to 5."),
      comment: z.string().max(4000).nullish(),
      screen: z.string().regex(/^\/[a-z0-9/[\]._-]{0,119}$/).catch("unknown"),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const user = context.locals.user;
      if (!user) throw new ActionError({ code: "UNAUTHORIZED", message: "Your session ended. Sign in again, then send it." });

      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          rating: input.rating,
          comment: input.comment ?? "",
          page: pageFrom(context.request.headers.get("referer")),
          screen: input.screen,
          user_id: user.id,
          email: user.email,
          _gotcha: input._gotcha ?? "",
        }),
        signal: AbortSignal.timeout(8000),
      }).catch(() => null);

      const result = await response?.json().catch(() => null);
      if (result?.ok) return { sent: true };
      throw new ActionError({
        code: "BAD_GATEWAY",
        message: result?.error?.message ?? "We couldn't send it. Try again in a moment.",
      });
    },
  }),
};
```

- Le second argument du handler est le contexte de l'action. Il contient `locals`, `request` et `cookies`, mais pas `redirect` : une session manquante devient donc une erreur `UNAUTHORIZED` que le formulaire peut afficher.
- `.catch("unknown")` sur `screen` transforme un libellé forgé en `unknown` au lieu de faire échouer toute la réponse.
- Les inputs vides arrivent à une action `form` sous la forme `null`, c'est pourquoi `comment` et le honeypot sont `nullish`.
- L'envoi du formulaire comme l'appel du script sont émis par la page : `Referer` contient donc son adresse dans les deux cas. `pageFrom` garde l'origine et le chemin.
- `Origin` est transmis pour que les tests locaux comptent comme `localhost`. Plus de détails ci-dessous.

### Le composant formulaire

`src/components/FeedbackForm.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";

const result = Astro.getActionResult(actions.sendFeedback);
const scores = ["Broken", "Frustrating", "Fine", "Good", "Great"];
---

{result?.data?.sent ? (
  <p role="status">Thanks. We read every one.</p>
) : (
  <form method="POST" action={actions.sendFeedback} data-feedback>
    <fieldset>
      <legend>How is this screen working for you?</legend>
      {scores.map((label, index) => (
        <label><input type="radio" name="rating" value={index + 1} required /> {label}</label>
      ))}
    </fieldset>
    <label for="feedback-comment">What would make it better?</label>
    <textarea id="feedback-comment" name="comment" rows="4" maxlength="4000"></textarea>
    <input type="hidden" name="screen" value={Astro.routePattern} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p role="alert">
      {result?.error && (isInputError(result.error) ? result.error.fields.rating?.[0] : result.error.message)}
    </p>
    <button>Send feedback</button>
  </form>
)}

<script>
  import { actions } from "astro:actions";

  const form = document.querySelector<HTMLFormElement>("form[data-feedback]");
  form?.addEventListener("submit", async (event) => {
    event.preventDefault();
    const button = form.querySelector("button")!;
    const alert = form.querySelector("[role=alert]")!;
    button.disabled = true;
    button.textContent = "Sending";
    const { error } = await actions.sendFeedback(new FormData(form));
    if (!error) {
      form.replaceWith(Object.assign(document.createElement("p"), { role: "status", textContent: "Thanks. We read every one." }));
      return;
    }
    alert.textContent = error.message;
    button.disabled = false;
    button.textContent = "Send feedback";
  });
</script>
```

- `action={actions.sendFeedback}` fait fonctionner le formulaire sans aucun JavaScript. Astro gère l'envoi et affiche la page avec le résultat.
- `isInputError` distingue les échecs du schéma, qui ont des `fields`, des erreurs levées par le handler, comme le message de session.
- Le script importe `actions` depuis `astro:actions` et appelle le même handler avec les données du formulaire. Astro le bundle, il n'y a donc pas de fichier supplémentaire.
- Un envoi sans JavaScript laisse la page comme résultat d'un POST, et un rafraîchissement demande de renvoyer. Si cela vous gêne, `getActionContext` dans le middleware permet de stocker le résultat et de rediriger.

### La page

`src/pages/app/projects/[id]/settings.astro`:

```astro
---
import AppLayout from "../../../../layouts/AppLayout.astro";
import FeedbackForm from "../../../../components/FeedbackForm.astro";

export const prerender = false;

if (!Astro.locals.user) return Astro.redirect("/sign-in");
---

<AppLayout title="Project settings">
  <h1>Project settings</h1>
  <aside>
    <FeedbackForm />
  </aside>
</AppLayout>
```

## Mode de rendu

Une action de formulaire ne fonctionne que sur une page rendue à la demande. Les pages prérendues sont construites une seule fois, il n'y a donc aucune requête vers laquelle envoyer. Avec la sortie statique par défaut, marquez chaque page de l'application avec `export const prerender = false`, ou réglez `output: "server"` si la majeure partie du site est derrière une connexion. Le middleware s'exécute quand même sur les pages prérendues au moment du build, où il n'y a pas de cookie : `locals.user` y vaut donc `null`.

## Honeypot, vérification humaine et limites

L'input masqué `_gotcha` est transmis, et un honeypot rempli est stocké comme spam. La vérification humaine ne s'exécute jamais pour un envoi avec clé secrète, et c'est l'obligation de connexion qui joue ce rôle. Astro vérifie par défaut l'`Origin` des envois de formulaire vers les pages à la demande : un autre site ne peut donc pas soumettre le formulaire dans le navigateur d'un utilisateur.

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.

## Déployer

| Hébergeur | Adaptateur | Le secret |
| --- | --- | --- |
| Vercel | `@astrojs/vercel` | Ajoutez `IKIFORM_SECRET_KEY` dans les variables d'environnement du projet. Si vous utilisez les sessions Astro, configurez un driver de stockage ; Vercel n'en a pas par défaut. |
| Netlify | `@astrojs/netlify` | Ajoutez-la dans les variables d'environnement du site. Les sessions utilisent Netlify Blobs par défaut. |
| Cloudflare | `@astrojs/cloudflare` | Lancez `npx wrangler secret put IKIFORM_SECRET_KEY` et mettez-la dans `.dev.vars` en local. `astro:env/server` la lit, tout comme `env` de `cloudflare:workers`. Les sessions utilisent un binding KV nommé `SESSION`. |

Depuis Astro 6, `Astro.locals.runtime.env` a disparu de l'adaptateur Cloudflare. Le code qui y lisait la clé a besoin de l'import `astro:env` ci-dessus.

## Tests locaux gratuits

Un espace de travail sans offre a droit à 50 soumissions de test gratuites par mois depuis `localhost`, qu'Ikiform reconnaît grâce à l'en-tête `Origin`. Le navigateur envoie `http://localhost:4321` à `astro dev`, mais le `fetch` de l'action vers Ikiform n'envoie aucune origine. La ligne `Origin` transmet celle du navigateur : les tests locaux sont donc gratuits, et en production l'en-tête est votre propre domaine.

## Vérifiez que tout fonctionne

1. Lancez `astro dev`, connectez-vous, ouvrez la page de réglages d'un projet et envoyez un 2 avec un commentaire.
2. Dans **Submissions**, vérifiez que `screen` vaut `/app/projects/[id]/settings`, que `page` contient le vrai id et que `user_id` et `email` sont les vôtres.
3. Désactivez JavaScript et envoyez à nouveau. La page se recharge avec la ligne de remerciement.
4. Supprimez le cookie de session et envoyez. Le formulaire affiche le message de connexion.
5. Répondez à l'e-mail de notification. Il est adressé à l'utilisateur connecté.

## Pièges

| Problème | Que faire |
| --- | --- |
| Le formulaire envoie mais la page n'affiche aucun résultat | La page est prérendue. Ajoutez `export const prerender = false` ou passez à `output: "server"`. |
| `IKIFORM_SECRET_KEY is missing` au démarrage du build | Les secrets sont validés dès que `astro:env/server` est importé. Fournissez la variable au build, ou une valeur bidon au build et la vraie valeur à l'exécution. |
| `402 plan_required` sur les tests locaux | L'`Origin` transmis est vide ou n'est pas `localhost`. Ouvrez l'application sur `localhost:4321` et gardez la ligne `Origin`. |
| `locals.user` vaut toujours `null` en production | Le nom ou le domaine du cookie diffère selon l'environnement, ou la page est encore prérendue. Loguez `context.cookies.has("session")` dans le middleware. |
| `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 connecté dans une action Astro ?

Placez-le dans context.locals dans src/middleware.ts, puis lisez context.locals.user depuis le second argument du handler. Les sessions Astro sont l'autre voie, via context.session.

### Les actions Astro fonctionnent-elles sans JavaScript ?

Oui, sur les pages rendues à la demande. Réglez l'action du formulaire sur l'action et sa méthode sur POST, puis lisez le résultat avec Astro.getActionResult() dans la page.

### Comment savoir de quel écran vient le feedback dans Astro ?

Affichez Astro.routePattern dans un input masqué. Il donne le motif du fichier de route, comme /app/projects/[id]/settings, pour que les réponses se regroupent par écran plutôt que par id.

### 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 SvelteKit](https://ikiform.com/fr/guides/feedback-form/sveltekit.md): Un formulaire de feedback dans une application SvelteKit : un formulaire de layout envoie à une seule form action, qui ajoute côté serveur l'utilisateur depuis locals et la page depuis la requête.
- [Formulaire de feedback Nuxt](https://ikiform.com/fr/guides/feedback-form/nuxt.md): Un formulaire de feedback dans une application Nuxt : une route serveur qui lit la session utilisateur, prend la page dans Referer et fonctionne même quand JavaScript n'est pas chargé.
- [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.
- [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é.
