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

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

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](https://ikiform.com/fr/guides/newsletter-signup.md#form) 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](https://ikiform.com/fr/docs/guides/astro.md).

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

Schéma : Deux façons pour une inscription depuis le pied de page Astro d'atteindre la même action

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`:

```text
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`:

```js
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`:

```bash
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](https://ikiform.com/fr/guides/newsletter-signup.md#fields) dans l'onglet **Fields** du formulaire.

## La construire

### 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";

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

### Le composant de pied de page

`src/components/SignupForm.astro`:

```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`:

```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](https://ikiform.com/fr/guides/newsletter-signup/mailerlite.md) : il cherche d'abord l'adresse, pour qu'un lecteur confirmé qui s'inscrit à nouveau garde son statut.

`src/pages/api/ikiform.ts`:

```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](https://ikiform.com/fr/guides/spam-protection/cloudflare-turnstile.md), 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

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

### Une Astro Action peut-elle s'exécuter sur une page prérendue ?

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.

### Ai-je besoin d'un adaptateur pour un formulaire de newsletter dans Astro ?

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.

### Pourquoi transmettre les inscriptions à MailerLite via un webhook plutôt que dans l'action ?

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.

### 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 d'inscription à la newsletter](https://ikiform.com/fr/guides/newsletter-signup.md): Créez un formulaire d'inscription à la newsletter avec votre propre markup, enregistrez le consentement, bloquez les inscriptions de bots et transmettez chaque abonné à Kit, MailerLite, beehiiv ou n'importe quel outil.
- [Site statique](https://ikiform.com/fr/guides/newsletter-signup/static-site.md): Ajoutez un formulaire d'inscription à la newsletter à un site statique Hugo, Jekyll, Eleventy ou Astro, gardez la clé d'API de l'outil d'e-mailing hors de la page et confirmez dans l'outil.
- [MailerLite](https://ikiform.com/fr/guides/newsletter-signup/mailerlite.md): Reliez votre propre formulaire d'inscription à MailerLite : un relais ajoute les nouveaux lecteurs comme non confirmés pour le double opt-in, sans jamais demander deux fois à un abonné de confirmer.
- [Formulaire de newsletter Next.js](https://ikiform.com/fr/guides/newsletter-signup/nextjs.md): Un formulaire de newsletter en pied de page Next.js : une server action avec vérification du consentement et Turnstile, qui envoie à Ikiform, plus un route handler qui ajoute les lecteurs à Kit.
- [Double opt-in](https://ikiform.com/fr/guides/newsletter-signup/double-opt-in.md): Ikiform n'envoie pas d'e-mails de confirmation : le double opt-in se fait donc dans votre outil d'e-mailing. Un relais ajoute chaque inscription comme non confirmée et l'outil envoie le lien.
- [Kit (ConvertKit)](https://ikiform.com/fr/guides/newsletter-signup/kit.md): Reliez votre propre formulaire de newsletter à Kit avec un relais qui crée l'abonné, l'ajoute à un formulaire Kit pour l'incentive email et étiquette l'emplacement.
