Formulaire d'inscription à la newsletter9 min de lecture
Créer un formulaire d'inscription à la newsletter avec Astro
Un formulaire de newsletter Astro qui reste sur des pages prérendues : une Astro Action vérifie le consentement et envoie à Ikiform, et un endpoint ajoute les lecteurs à MailerLite.
Gardez vos pages Astro prérendues et placez le formulaire d'inscription dans le composant de pied de page. Sans JavaScript, il envoie à une page /newsletter rendue à la demande qui exécute une Astro Action ; avec JavaScript, un petit script appelle la même action et le lecteur ne quitte jamais l'article. L'action valide l'e-mail et le consentement avec astro/zod, envoie à votre backend de formulaires avec une clé secrète, et un endpoint d'API transmet les inscriptions vérifiées à MailerLite.
L'onglet Astro du guide principal utilise une Action sur une page rendue à la demande. La plupart des sites Astro sont des blogs et des documentations où chaque page est prérendue, et un formulaire de pied de page doit vivre sur toutes. Cette page garde ces pages statiques tout en exécutant l'inscription côté serveur. Les clés et les domaines autorisés sont dans le guide Astro de la documentation.
Ce que changent les pages statiques
La documentation d'Astro indique qu'une page doit être rendue à la demande pour appeler une action via l'attribut action d'un formulaire. Un article prérendu ne peut pas traiter ce POST, car aucun serveur n'est derrière. Il y a deux façons de contourner cela, et cette version utilise les deux :
| Chemin | Quand il s'exécute | Ce que voit le lecteur |
|---|---|---|
Le script appelle actions.subscribe() | JavaScript chargé, le cas habituel | La ligne d'état sous le formulaire change. Il reste sur l'article |
Le formulaire envoie à /newsletter?_action=... | JavaScript désactivé ou pas encore chargé | La page /newsletter à la demande, avec le résultat ou l'erreur |
Seuls /newsletter et l'endpoint du webhook s'exécutent à la demande. Chaque article reste un fichier statique, et l'adaptateur ajoute une fonction serveur pour ces deux routes.
- Avec JavaScript, le script du pied de page bloque l'envoi normal et appelle l'action avec les données du formulaire.
- L'action valide la saisie, envoie à Ikiform et renvoie des données ou une erreur au script, qui met à jour la ligne d'état.
- Sans JavaScript, le navigateur envoie le formulaire à la page /newsletter rendue à la demande, avec l'action dans l'URL.
- Astro exécute le même handler d'action, et la page lit le résultat avec getActionResult et l'affiche.
Mise en place du projet
src/
actions/index.ts
components/SignupForm.astro
layouts/Base.astro
pages/
newsletter.astro
api/ikiform.ts
astro.config.mjs
.envInstallez un adaptateur avec astro add vercel, astro add netlify ou astro add cloudflare. Déclarez les secrets avec astro:env plutôt que de lire import.meta.env : le schéma fait échouer le build quand une variable manque, et le même import fonctionne sur tous les adaptateurs, y compris le runtime de Cloudflare.
import { defineConfig, envField } from "astro/config";
import vercel from "@astrojs/vercel";
export default defineConfig({
adapter: vercel(),
env: {
schema: {
IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
IKIFORM_WEBHOOK_SECRET: envField.string({ context: "server", access: "secret" }),
MAILERLITE_API_KEY: envField.string({ context: "server", access: "secret" }),
MAILERLITE_GROUP_ID: envField.string({ context: "server", access: "secret" }),
},
},
});IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789Créez la clé secrète sous API keys avec le scope submissions:write, et collez la définition des champs du guide principal dans l'onglet Fields du formulaire.
La construire
L'action du formulaire
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
subscribe: defineAction({
accept: "form",
input: z.object({
email: z.email({ error: "Enter an email address we can send the newsletter to." }),
first_name: z.string().trim().max(80).nullish(),
consent: z.boolean().refine((ticked) => ticked, { error: "Tick the box so we know you want the emails." }),
source: z.enum(["footer", "post", "newsletter-page"]).catch("footer"),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
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({
email: input.email,
first_name: input.first_name || undefined,
consent: true,
source: input.source,
_gotcha: input._gotcha ?? "",
}),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return { subscribed: true };
throw new ActionError({
code: response?.status === 429 ? "TOO_MANY_REQUESTS" : "BAD_REQUEST",
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "We couldn't reach the list. Try again.",
});
},
}),
};accept: "form"fait analyser leFormDatapar Astro. La case passe par une conversion en booléen : cochée vauttrueet décochéefalse, querefinetransforme en message de consentement.- Les inputs texte vides arrivent sous la forme
null: les champs facultatifs utilisent doncnullish(), pasoptional(). sourcerevient àfooterpour toute valeur hors de la liste, pour qu'un envoi forgé ne puisse pas inventer d'emplacements.- La ligne
Origintransmet l'origine du navigateur, car c'est grâce à cet en-tête qu'Ikiform reconnaît les tests gratuits depuislocalhost, et lefetchd'un serveur n'en envoie aucun. - Les erreurs d'Ikiform deviennent une
ActionError. Un429garde son sens sous la formeTOO_MANY_REQUESTS.
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Le composant de pied de page
---
import { actions } from "astro:actions";
interface Props {
source?: "footer" | "post" | "newsletter-page";
}
const { source = "footer" } = Astro.props;
const target = new URL("/newsletter", Astro.url);
target.search = actions.subscribe.queryString;
---
<form class="signup" method="POST" action={target.pathname + target.search}>
<label>Email <input name="email" type="email" required autocomplete="email" /></label>
<label>First name (optional) <input name="first_name" maxlength="80" autocomplete="given-name" /></label>
<label>
<input name="consent" type="checkbox" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" value={source} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p class="signup-status" role="status"></p>
<button>Subscribe</button>
</form>
<script>
import { actions, isInputError } from "astro:actions";
for (const form of document.querySelectorAll<HTMLFormElement>("form.signup")) {
const status = form.querySelector(".signup-status") as HTMLElement;
const button = form.querySelector("button") as HTMLButtonElement;
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true;
status.textContent = "Subscribing";
const { error } = await actions.subscribe(new FormData(form));
button.disabled = false;
if (!error) {
form.replaceChildren(status);
status.textContent = "Almost done. Open the email we sent you and confirm your address.";
return;
}
const fields = isInputError(error) ? error.fields : {};
status.textContent = fields.email?.[0] ?? fields.consent?.[0] ?? error.message;
});
}
</script>queryStringest la façon documentée de construire l'URL d'une action. La placer sur une URL/newsletterenvoie les envois sans JavaScript vers la page à la demande plutôt que vers la page statique d'où ils viennent.- Astro bundle une seule fois le
<script>d'un composant, quel que soit le nombre d'apparitions du composant. La boucle branche chaque copie, comme un formulaire de pied de page et un autre en fin d'article. isInputErrordistingue les messages par champ de zod des refus d'Ikiform, qui arrivent sous la forme d'un simple message.
La page à la demande
---
import { actions, isInputError } from "astro:actions";
import SignupForm from "../components/SignupForm.astro";
import Base from "../layouts/Base.astro";
export const prerender = false;
const result = Astro.getActionResult(actions.subscribe);
const fields = isInputError(result?.error) ? result.error.fields : {};
const problem = fields.email?.[0] ?? fields.consent?.[0] ?? result?.error?.message;
---
<Base title="Newsletter">
<h1>The monthly newsletter</h1>
{result?.data?.subscribed ? (
<p role="status">Almost done. Open the email we sent you and confirm your address.</p>
) : (
<>
{problem && <p role="alert">{problem}</p>}
<SignupForm source="newsletter-page" />
</>
)}
</Base>Astro n'affiche le résultat d'une action que pour ce POST. Revenir sur la page l'efface, ce qui convient pour une inscription. Si vous préférez rediriger vers une URL de remerciement, la documentation d'Astro montre le modèle basé sur les sessions avec getActionContext dans le middleware.
Transmettre les abonnés à MailerLite
Cet endpoint reçoit le webhook signé d'Ikiform et ajoute chaque inscription à un groupe MailerLite en unconfirmed, pour que MailerLite envoie sa propre confirmation quand Double opt-in for API and integrations est activé. Il lit d'abord le corps comme du texte, car la signature couvre les octets exacts. Il envoie chaque inscription en unconfirmed, ce qui convient à une nouvelle liste. Si votre liste a déjà des lecteurs confirmés, utilisez plutôt le relais du guide MailerLite : il cherche d'abord l'adresse, pour qu'un lecteur confirmé qui s'inscrit à nouveau garde son statut.
import type { APIRoute } from "astro";
import { IKIFORM_WEBHOOK_SECRET, MAILERLITE_API_KEY, MAILERLITE_GROUP_ID } from "astro:env/server";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
let event;
try {
event = await verifyWebhook({ payload: await request.text(), headers: request.headers, secret: IKIFORM_WEBHOOK_SECRET });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const { email, first_name, consent } = event.data.fields;
if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });
const answer = await fetch("https://connect.mailerlite.com/api/subscribers", {
method: "POST",
headers: {
Authorization: `Bearer ${MAILERLITE_API_KEY}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
email,
fields: typeof first_name === "string" && first_name ? { name: first_name } : {},
groups: [MAILERLITE_GROUP_ID],
status: "unconfirmed",
}),
});
if (answer.status === 429) return new Response("MailerLite rate limit", { status: 503, headers: { "retry-after": "60" } });
if (!answer.ok) return new Response(`MailerLite answered ${answer.status}`, { status: 502 });
return new Response(null, { status: 204 });
};Le security.checkOrigin d'Astro, activé par défaut, rejette les envois de formulaire cross-site vers les routes à la demande. Le webhook d'Ikiform est en application/json, un type auquel la vérification ne s'applique pas : l'endpoint le reçoit donc tel quel. Ajoutez l'URL sous Webhooks et copiez le secret whsec_ dans IKIFORM_WEBHOOK_SECRET.
Honeypot et vérification humaine
L'input _gotcha est transmis et Ikiform stocke un honeypot rempli comme spam, avec une réponse de succès et sans webhook : il n'atteint donc jamais MailerLite. La clé secrète contourne la vérification Turnstile d'Ikiform. Si des bots passent, vous avez deux choix : vérifier un token Turnstile dans le handler, comme le montre le guide Turnstile, ou abandonner l'action et envoyer depuis le navigateur avec @ikiform/sdk, qui conserve la vérification d'Ikiform, les domaines autorisés et les limites par visiteur, sans aucun adaptateur.
Déployer
| Hébergeur | Que faire |
|---|---|
| Vercel | @astrojs/vercel transforme les deux routes à la demande en fonctions. Ajoutez les quatre variables sous Environment Variables. |
| Netlify | @astrojs/netlify fait de même avec les Netlify Functions. Ajoutez les variables dans les réglages d'environnement du site. |
| Cloudflare | @astrojs/cloudflare cible Workers ; l'adaptateur ne prend plus en charge Pages. Définissez les secrets avec wrangler secret put, et utilisez .dev.vars en local. Donnez au Worker un domaine personnalisé, car Ikiform refuse les URL de webhook workers.dev. |
Tester gratuitement sur localhost
Un espace de travail sans offre accepte 50 soumissions de test par mois depuis localhost. Lancez astro dev : l'Origin transmis est http://localhost:4321, chaque inscription compte donc comme un test. Essayez les deux chemins : une fois normalement, et une fois avec JavaScript désactivé pour voir la page /newsletter. Pour tester l'endpoint, exposez-le via un tunnel avec une URL HTTPS ; Ikiform ne livre pas à localhost.
Vérifiez que tout fonctionne
- Lancez
astro buildet vérifiez dans la sortie que vos articles sont toujours prérendus, avec seulement/newsletteret/api/ikiformà la demande. - Abonnez-vous depuis le pied de page d'un article. La ligne d'état change, pas l'URL.
- Désactivez JavaScript et abonnez-vous à nouveau. Vous arrivez sur
/newsletteravec la ligne invitant à vérifier votre boîte. - Envoyez avec la case décochée depuis les outils de développement. Le message de consentement s'affiche et rien n'est stocké.
- Ouvrez Submissions dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
- Si vous avez ajouté un webhook, ouvrez l'endpoint dans Webhooks. La livraison affiche
succeededavec la réponse de votre serveur. - Ouvrez MailerLite. L'abonné est dans le groupe en non confirmé, et l'e-mail de confirmation est dans votre boîte.
Pièges
| Problème | Que faire |
|---|---|
| L'envoi sans JavaScript affiche une 404 ou une page statique | Le formulaire a envoyé à la page prérendue d'où il vient. Construisez l'action à partir de /newsletter plus queryString, et gardez prerender = false sur cette page. |
| Seul le premier formulaire d'une page fonctionne | Le script a utilisé querySelector pour un seul formulaire. Astro l'exécute une fois par page : bouclez donc sur chaque form.signup. |
| Le build échoue à cause d'une variable d'environnement manquante | astro:env vérifie le schéma au build. Définissez la variable chez l'hébergeur, ou dans .env en local. |
402 plan_required sur les tests locaux | L'envoi n'avait pas d'origine localhost. Vérifiez la ligne Origin dans le handler, et ouvrez le site sur localhost. |
| La soumission est dans le tableau de bord mais aucun e-mail ni webhook n'est arrivé | Elle a été stockée comme spam. Le spam n'envoie rien. Ouvrez-la sous le filtre Spam pour voir les raisons, et marquez-la comme non-spam pour envoyer son e-mail et ses webhooks. |
Questions
Appelée depuis un script client, oui, à condition que le projet ait un adaptateur : l'action s'exécute sur sa propre route à la demande. Un simple envoi de formulaire HTML a besoin d'une page à la demande où arriver, c'est pourquoi cette version l'envoie à /newsletter.
Seulement pour la version côté serveur de cette page. Un site Astro entièrement statique peut envoyer le formulaire directement du navigateur à Ikiform, ce qui conserve la vérification humaine d'Ikiform et ne demande aucun serveur.
L'action ne peut pas distinguer un bot d'un lecteur : Ikiform donne aux deux la même réponse de succès. Son webhook ne se déclenche que pour les inscriptions qui passent la vérification anti-spam, et une panne de MailerLite retarde alors l'abonné via les nouvelles tentatives du webhook au lieu de faire échouer l'envoi du visiteur.
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.