Formulaire d'inscription à la newsletter

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.

Preet SutharFondateur, Ikiform

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 :

CheminQuand il s'exécuteCe que voit le lecteur
Le script appelle actions.subscribe()JavaScript chargé, le cas habituelLa 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.

Deux façons pour une inscription depuis le pied de page Astro d'atteindre la même actionavec JavaScriptsans JavaScriptarticleHTMLprérenduactions.subscribe()depuislescriptclienthandlerdel'actionzod,puisfetchligned'étatmêmepagearticleenvoideformulairesimple/newsletterprerender=falsehandlerdel'actionmêmecodepagederésultatgetActionResultFormData/_actions/subscribe{ data, error }POST?_actionrésultatDeux façons pour une inscription depuis le pied de page Astro d'atteindre la même actionavec JavaScriptsans JavaScriptarticleHTML prérenduactions.subscribe()depuis le script clienthandler de l'actionzod, puis fetchligne d'étatmême pagearticleenvoi de formulaire simple/newsletterprerender = falsehandler de l'actionmême codepage de résultatgetActionResultFormData/_actions/subscribe{ data, error }POST?_actionrésultat
  1. Avec JavaScript, le script du pied de page bloque l'envoi normal et appelle l'action avec les données du formulaire.
  2. L'action valide la saisie, envoie à Ikiform et renvoie des données ou une erreur au script, qui met à jour la ligne d'état.
  3. Sans JavaScript, le navigateur envoie le formulaire à la page /newsletter rendue à la demande, avec l'action dans l'URL.
  4. Astro exécute le même handler d'action, et la page lit le résultat avec getActionResult et l'affiche.
Un seul handler, deux points d'entrée. L'article statique n'a jamais besoin de son propre serveur.

Mise en place du projet

Arborescence du projet
src/
  actions/index.ts
  components/SignupForm.astro
  layouts/Base.astro
  pages/
    newsletter.astro
    api/ikiform.ts
astro.config.mjs
.env

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

astro.config.mjs
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" }),
    },
  },
});
.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789

Cré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

src/actions/index.ts
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 le FormData par Astro. La case passe par une conversion en booléen : cochée vaut true et décochée false, que refine transforme en message de consentement.
  • Les inputs texte vides arrivent sous la forme null : les champs facultatifs utilisent donc nullish(), pas optional().
  • source revient à footer pour toute valeur hors de la liste, pour qu'un envoi forgé ne puisse pas inventer d'emplacements.
  • La ligne Origin transmet l'origine du navigateur, car c'est grâce à cet en-tête qu'Ikiform reconnaît les tests gratuits depuis localhost, et le fetch d'un serveur n'en envoie aucun.
  • Les erreurs d'Ikiform deviennent une ActionError. Un 429 garde son sens sous la forme TOO_MANY_REQUESTS.
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.

Le composant de pied de page

src/components/SignupForm.astro
---
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>
  • queryString est la façon documentée de construire l'URL d'une action. La placer sur une URL /newsletter envoie 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.
  • isInputError distingue les messages par champ de zod des refus d'Ikiform, qui arrivent sous la forme d'un simple message.

La page à la demande

src/pages/newsletter.astro
---
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.

src/pages/api/ikiform.ts
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ébergeurQue 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

  1. Lancez astro build et vérifiez dans la sortie que vos articles sont toujours prérendus, avec seulement /newsletter et /api/ikiform à la demande.
  2. Abonnez-vous depuis le pied de page d'un article. La ligne d'état change, pas l'URL.
  3. Désactivez JavaScript et abonnez-vous à nouveau. Vous arrivez sur /newsletter avec la ligne invitant à vérifier votre boîte.
  4. Envoyez avec la case décochée depuis les outils de développement. Le message de consentement s'affiche et rien n'est stocké.
  5. Ouvrez Submissions dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
  6. Si vous avez ajouté un webhook, ouvrez l'endpoint dans Webhooks. La livraison affiche succeeded avec la réponse de votre serveur.
  7. 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èmeQue faire
L'envoi sans JavaScript affiche une 404 ou une page statiqueLe 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 fonctionneLe 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 manquanteastro: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 locauxL'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

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.