Formulaire de feedback pour un site ou une application8 min de lecture
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.
É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 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 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.
- Sans JavaScript, le navigateur envoie le formulaire à la page courante, avec le nom de l'action dans l'URL.
- Le middleware s'exécute en premier à chaque requête et place l'utilisateur connecté dans context.locals.
- L'action valide la note et l'écran, ajoute l'utilisateur et la page, et envoie à Ikiform avec le secret.
- Astro affiche à nouveau la page, et Astro.getActionResult() montre la ligne de remerciement ou l'erreur.
- Avec JavaScript, le script appelle la même action via fetch. Le middleware et le handler s'exécutent exactement comme avant.
- Le script remplace le formulaire par la ligne de remerciement, ou écrit l'erreur en dessous, sans rechargement.
Mise en place du projet
src/
actions/index.ts
components/FeedbackForm.astro
middleware.ts
env.d.ts
pages/app/projects/[id]/settings.astro
astro.config.mjs
.envDé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.
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 :
Aperçu
Exécute le validateur d'Ikiform dans votre navigateur. Rien n'est envoyé.
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.
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();
});declare namespace App {
interface Locals {
user: { id: string; email: string } | null;
}
}L'action du formulaire
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,requestetcookies, mais pasredirect: une session manquante devient donc une erreurUNAUTHORIZEDque le formulaire peut afficher. .catch("unknown")surscreentransforme un libellé forgé enunknownau lieu de faire échouer toute la réponse.- Les inputs vides arrivent à une action
formsous la formenull, c'est pourquoicommentet le honeypot sontnullish. - L'envoi du formulaire comme l'appel du script sont émis par la page :
Referercontient donc son adresse dans les deux cas.pageFromgarde l'origine et le chemin. Originest transmis pour que les tests locaux comptent commelocalhost. Plus de détails ci-dessous.
Le composant formulaire
---
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.isInputErrordistingue les échecs du schéma, qui ont desfields, des erreurs levées par le handler, comme le message de session.- Le script importe
actionsdepuisastro:actionset 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,
getActionContextdans le middleware permet de stocker le résultat et de rediriger.
La page
---
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 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
- Lancez
astro dev, connectez-vous, ouvrez la page de réglages d'un projet et envoyez un 2 avec un commentaire. - Dans Submissions, vérifiez que
screenvaut/app/projects/[id]/settings, quepagecontient le vrai id et queuser_idetemailsont les vôtres. - Désactivez JavaScript et envoyez à nouveau. La page se recharge avec la ligne de remerciement.
- Supprimez le cookie de session et envoyez. Le formulaire affiche le message de connexion.
- 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
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.
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.
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.
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.