# Créer un formulaire d'inscription à une newsletter avec Nuxt

> Un formulaire de newsletter Nuxt qui fonctionne avec ou sans JavaScript : une route serveur avec vérification Turnstile envoie à Ikiform, et une route Nitro alimente beehiiv.

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

Donnez au formulaire une vraie `action` pointant vers une route serveur Nuxt, et améliorez-le avec `$fetch` pour que les lecteurs avec JavaScript restent sur la page. La route vérifie la case de consentement et un jeton Turnstile de `@nuxtjs/turnstile`, transmet l'inscription à votre backend de formulaires avec une clé secrète, et répond en JSON ou par une redirection selon l'appelant. Une seconde route transforme le webhook signé du backend en abonnement beehiiv.

L'[onglet Nuxt du guide principal](https://ikiform.com/fr/guides/newsletter-signup.md#form) envoie avec `$fetch` et fonctionne une fois Vue hydraté. Cette page fait fonctionner le même formulaire avant cela, ajoute une vérification humaine et transmet les vraies inscriptions à beehiiv. La configuration du produit se trouve dans le [guide Nuxt de la documentation](https://ikiform.com/fr/docs/guides/nuxt.md).

## Une route, deux types d'appelants

Une route serveur Nuxt ne sait pas si c'est un formulaire du navigateur ou `$fetch` qui l'a appelée, donc elle décide d'après l'en-tête `Accept`. `$fetch` depuis le composant demande du JSON et reçoit du JSON ou une erreur avec un motif. Un envoi de formulaire classique, avant l'hydratation ou avec les scripts désactivés, demande du HTML, donc la route répond `303` vers `/newsletter` avec un code de résultat :

Schéma : Comment la route de newsletter Nuxt répond à chaque type d'appelant

1. Le formulaire envoie à /api/newsletter, via $fetch une fois hydraté ou par un envoi de formulaire classique avant cela.
2. La route vérifie le consentement et le jeton Turnstile, puis transmet l'inscription à Ikiform.
3. Elle lit l'en-tête Accept pour décider comment répondre.
4. $fetch a demandé du JSON : un succès renvoie du JSON, et un problème lève une erreur avec un motif que le composant affiche.
5. Un envoi de formulaire classique reçoit une redirection 303 vers /newsletter avec un code de résultat, et la page transforme le code en message.

La redirection transporte un code, jamais un message, donc un lien forgé ne peut pas afficher son propre texte sur votre page.

## Mise en place du projet

Installez le module Turnstile avec `npx nuxi@latest module add turnstile`, puis :

`Arborescence du projet`:

```text
app/
  components/NewsletterForm.vue
  pages/newsletter.vue
server/
  api/newsletter.post.ts
  routes/hooks/ikiform.post.ts
nuxt.config.ts
.env
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  modules: ["@nuxtjs/turnstile"],
  turnstile: { siteKey: "your_turnstile_site_key" },
  runtimeConfig: {
    ikiformSecretKey: "",
    ikiformWebhookSecret: "",
    beehiivApiKey: "",
    beehiivPublicationId: "",
    turnstile: { secretKey: "" },
  },
  routeRules: {
    "/blog/**": { prerender: true },
  },
});
```

`.env`:

```bash
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
NUXT_BEEHIIV_API_KEY=your_beehiiv_api_key
NUXT_BEEHIIV_PUBLICATION_ID=pub_00000000-0000-0000-0000-000000000000
NUXT_TURNSTILE_SECRET_KEY=your_turnstile_secret_key
```

Chaque clé sous `runtimeConfig` est remplacée à l'exécution par la variable `NUXT_` correspondante, donc la config contient des chaînes vides et l'hébergeur contient les valeurs. Rien en dehors de `runtimeConfig.public` n'atteint le navigateur. La site key Turnstile est publique par conception. Créez la clé Ikiform 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

### La server route

`server/api/newsletter.post.ts`:

```ts
interface Signup {
  email?: string;
  first_name?: string;
  consent?: boolean | string;
  source?: string;
  _gotcha?: string;
  "cf-turnstile-response"?: string;
}

type Result = "subscribed" | "consent" | "check" | "email" | "failed";

const PLACEMENTS = ["footer", "post", "newsletter-page"];

export default defineEventHandler(async (event) => {
  const body = (await readBody<Signup>(event)) ?? {};
  const wantsJson = getRequestHeader(event, "accept")?.includes("application/json") ?? false;
  const config = useRuntimeConfig(event);

  const finish = (result: Result, status = 200, message = "") => {
    if (!wantsJson) return sendRedirect(event, `/newsletter?result=${result}`, 303);
    if (result === "subscribed") return { subscribed: true };
    throw createError({ status, message, data: { reason: result } });
  };

  if (body.consent !== true && body.consent !== "true") {
    return finish("consent", 422, "Tick the box so we know you want the emails.");
  }
  const check = await verifyTurnstileToken(String(body["cf-turnstile-response"] ?? ""), event);
  if (!check.success) return finish("check", 403, "The human check didn't pass. Try once more.");

  const source = PLACEMENTS.includes(String(body.source)) ? String(body.source) : "footer";
  const origin = getRequestHeader(event, "origin");
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${config.ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      email: String(body.email ?? "").trim(),
      first_name: String(body.first_name ?? "").trim().slice(0, 80) || undefined,
      consent: true,
      source,
      _gotcha: String(body._gotcha ?? ""),
    }),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return finish("subscribed");
  const emailError = result?.error?.fields?.email?.[0];
  if (emailError) return finish("email", 422, emailError);
  return finish("failed", response?.status ?? 502, result?.error?.message ?? "We couldn't reach the list. Try again.");
});
```

- `readBody` analyse à la fois le JSON de `$fetch` et le corps URL-encoded d'un envoi de formulaire classique. C'est pourquoi le consentement est accepté sous la forme `true` ou `"true"`.
- `verifyTurnstileToken` vient du module Turnstile et lit `runtimeConfig.turnstile.secretKey`. Les jetons sont à usage unique et expirent au bout de cinq minutes.
- 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.
- `useRuntimeConfig(event)` récupère les valeurs avec les remplacements `NUXT_` appliqués à l'exécution, ce que Nuxt recommande dans les routes serveur.

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

`app/components/NewsletterForm.vue`:

```vue
<script setup lang="ts">
const props = withDefaults(defineProps<{ source?: "footer" | "post" | "newsletter-page" }>(), { source: "footer" });

const turnstile = ref<{ reset: () => void }>();
const token = ref("");
const status = ref<"idle" | "sending" | "done">("idle");
const problem = ref<{ reason: string; message: string } | null>(null);

async function subscribe(event: Event) {
  const fields = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));
  status.value = "sending";
  problem.value = null;
  try {
    await $fetch("/api/newsletter", {
      method: "POST",
      headers: { Accept: "application/json" },
      body: { ...fields, consent: fields.consent === "true", "cf-turnstile-response": token.value },
    });
    status.value = "done";
  } catch (failure) {
    const data = (failure as { data?: { message?: string; data?: { reason?: string } } }).data;
    problem.value = { reason: data?.data?.reason ?? "failed", message: data?.message ?? "Couldn't subscribe. Try again." };
    turnstile.value?.reset();
    status.value = "idle";
  }
}
</script>

<template>
  <p v-if="status === 'done'" role="status">Almost done. Open the email we sent you and confirm your address.</p>
  <form v-else action="/api/newsletter" method="post" @submit.prevent="subscribe">
    <label>
      Email
      <input name="email" type="email" required autocomplete="email" :aria-invalid="problem?.reason === 'email'" />
    </label>
    <label>
      First name (optional)
      <input name="first_name" maxlength="80" autocomplete="given-name" />
    </label>
    <label>
      <input name="consent" type="checkbox" value="true" required :aria-invalid="problem?.reason === 'consent'" />
      Email me the newsletter. I can unsubscribe at any time.
    </label>
    <input type="hidden" name="source" :value="props.source" />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <NuxtTurnstile ref="turnstile" v-model="token" />
    <p v-if="problem" role="alert">{{ problem.message }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Subscribing" : "Subscribe" }}</button>
  </form>
</template>
```

- Le formulaire garde `action` et `method`, donc il envoie de façon classique jusqu'à ce que `@submit.prevent` prenne le relais après l'hydratation.
- `<NuxtTurnstile>` affiche le widget et place son jeton dans `token`. Après toute erreur, le jeton est consommé, donc `reset()` en demande un nouveau.
- Les champs sont lus depuis `FormData` avant le premier `await`, tant que `event.currentTarget` pointe encore vers le formulaire.

### La page /newsletter

`app/pages/newsletter.vue`:

```vue
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
  subscribed: "Almost done. Open the email we sent you and confirm your address.",
  consent: "Tick the box so we know you want the emails.",
  check: "The human check needs JavaScript. Turn it on and try again.",
  email: "Check the email address and try again.",
  failed: "We couldn't reach the list. Try again.",
};

const route = useRoute();
const message = computed(() => MESSAGES[String(route.query.result)] ?? "");
</script>

<template>
  <main>
    <h1>The monthly newsletter</h1>
    <p v-if="message" :role="route.query.result === 'subscribed' ? 'status' : 'alert'">{{ message }}</p>
    <NewsletterForm v-if="route.query.result !== 'subscribed'" source="newsletter-page" />
  </main>
</template>
```

Turnstile a besoin de JavaScript, donc un lecteur avec les scripts désactivés reçoit le message `check`. Si ce compromis ne convient pas à vos lecteurs, retirez les lignes Turnstile et comptez sur le honeypot et la case obligatoire.

## Transférer les abonnés vers beehiiv

Cette route se trouve dans `server/routes`, donc elle n'a pas de préfixe `/api`. Elle lit le corps avec `readRawBody` avant que quoi que ce soit ne l'analyse, car la signature couvre les octets exacts, et crée l'abonnement avec `double_opt_override: "on"` pour que beehiiv envoie la confirmation. Le [guide beehiiv](https://ikiform.com/fr/guides/newsletter-signup/beehiiv.md) ajoute le prénom et les en-têtes de limite de débit de beehiiv.

`server/routes/hooks/ikiform.post.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig(event);
  let delivery;
  try {
    delivery = await verifyWebhook({
      payload: (await readRawBody(event)) ?? "",
      headers: event.headers,
      secret: config.ikiformWebhookSecret,
    });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      setResponseStatus(event, 400);
      return error.reason;
    }
    throw error;
  }
  if (delivery.type !== "submission.created") return sendNoContent(event);

  const { email, consent, source } = delivery.data.fields;
  if (consent !== true || typeof email !== "string") return "Skipped";

  const answer = await fetch(
    `https://api.beehiiv.com/v2/publications/${config.beehiivPublicationId}/subscriptions`,
    {
      method: "POST",
      headers: { Authorization: `Bearer ${config.beehiivApiKey}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        email,
        double_opt_override: "on",
        reactivate_existing: false,
        utm_source: typeof source === "string" ? source : "website",
        referring_site: delivery.data.meta.referrer ?? undefined,
      }),
    },
  );
  if (answer.status === 429) {
    setResponseHeader(event, "retry-after", 60);
    setResponseStatus(event, 503);
    return "beehiiv rate limit";
  }
  if (!answer.ok) {
    setResponseStatus(event, 502);
    return `beehiiv answered ${answer.status}`;
  }
  return sendNoContent(event);
});
```

Ajoutez `https://your-site.com/hooks/ikiform` sous **Webhooks**, choisissez le formulaire de newsletter et copiez le secret `whsec_` dans `NUXT_IKIFORM_WEBHOOK_SECRET`.

## Honeypot et vérification humaine

L'input `_gotcha` est transmis et Ikiform enregistre un champ rempli comme spam, avec une réponse de succès et sans webhook, donc beehiiv ne le voit jamais. La clé secrète contourne la vérification Turnstile propre à Ikiform, c'est pourquoi la route en exécute une avec votre propre widget.

## Rendu : hybride, pas generate

`nuxt generate` écrit un site statique sans serveur, donc `/api/newsletter` et `/hooks/ikiform` n'existent plus ensuite. Compilez avec `nuxt build` et prérendez les pages qui peuvent être statiques via `routeRules`, comme le fait la config ci-dessus pour `/blog/**`. Les pages prérendues gardent le formulaire, et son envoi atteint toujours la route serveur.

## Déployer

| Hébergeur | Que faire |
| --- | --- |
| Vercel | Nitro détecte Vercel et compile les routes serveur en fonctions. Ajoutez les variables `NUXT_` sous **Environment Variables**. |
| Netlify | Nitro détecte Netlify de la même façon. Ajoutez les variables dans les paramètres d'environnement du site. |
| Cloudflare | Compilez avec `nuxt build`, pas `nuxt generate`, et définissez les variables `NUXT_` comme secrets du projet. Utilisez un domaine personnalisé pour l'URL du webhook ; Ikiform refuse `workers.dev` et `pages.dev`. |

## Tester gratuitement sur localhost

Un espace de travail sans offre accepte 50 soumissions de test par mois depuis `localhost`, et l'en-tête `Origin` transmis fait de chaque inscription depuis `nuxt dev` l'une d'elles. Utilisez en local les clés de test Turnstile publiées par Cloudflare pour que la vérification passe toujours. Ikiform ne livre pas de webhooks à `localhost` ; exposez le serveur de développement via un tunnel avec une URL HTTPS pour tester `/hooks/ikiform`.

## Vérifiez que tout fonctionne

1. Lancez `nuxt dev` et abonnez-vous depuis le pied de page. Le formulaire est remplacé par la ligne vous invitant à consulter votre boîte de réception et l'URL ne change pas.
2. Désactivez JavaScript et abonnez-vous. Vous arrivez sur `/newsletter?result=check`, car le widget n'a pas pu s'exécuter.
3. Avec JavaScript activé, retirez `required` de la case à cocher et envoyez-la décochée. Le message de consentement apparaît et rien n'est enregistré.
4. Ouvrez **Submissions** dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
5. Si vous avez ajouté un webhook, ouvrez l'endpoint dans **Webhooks**. La livraison affiche `succeeded` avec la réponse de votre serveur.
6. Ouvrez beehiiv. L'abonnement y figure, en attente jusqu'à votre confirmation, et l'e-mail de confirmation est dans votre boîte de réception.

## Pièges

| Problème | Que faire |
| --- | --- |
| `404` sur `/api/newsletter` en production | Le site a été compilé avec `nuxt generate`, qui n'a pas de serveur. Compilez avec `nuxt build` et prérendez les pages via `routeRules`. |
| La clé secrète est vide à l'exécution | Le nom de la variable ne correspond pas à la clé de config. `ikiformSecretKey` nécessite `NUXT_IKIFORM_SECRET_KEY`. |
| La vérification humaine échoue une fois sur deux | Le jeton a été réutilisé. Appelez `reset()` sur le widget après chaque erreur, comme le fait le composant. |
| `402 plan_required` sur les tests locaux | L'envoi n'avait pas d'origine `localhost`. Vérifiez la ligne `Origin`, 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

### Un formulaire Nuxt peut-il fonctionner sans JavaScript ?

Oui, s'il garde une vraie action et une vraie méthode. Faites-le pointer vers une route serveur qui redirige les envois de formulaire classiques et renvoie du JSON pour $fetch, comme sur cette page.

### nuxt generate prend-il en charge les routes serveur pour un formulaire de newsletter ?

Non. nuxt generate ne produit que des fichiers statiques, donc les routes serveur sont absentes. Utilisez nuxt build avec routeRules pour prérendre les pages statiques, ou envoyez le formulaire directement depuis le navigateur vers Ikiform.

### Pourquoi envoyer les abonnés à beehiiv depuis un webhook plutôt que depuis la route serveur ?

La route serveur ne voit que la réponse d'Ikiform, et le spam reçoit le même succès qu'une vraie inscription. beehiiv ne doit entendre parler d'un lecteur qu'après la vérification anti-spam, c'est-à-dire au moment où le webhook se déclenche.

### 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.
- [beehiiv](https://ikiform.com/fr/guides/newsletter-signup/beehiiv.md): Reliez votre propre formulaire d'inscription à beehiiv : un relais crée chaque abonnement avec le double opt-in activé, l'emplacement comme source UTM et la page comme referrer.
- [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.
- [Formulaire de newsletter SvelteKit](https://ikiform.com/fr/guides/newsletter-signup/sveltekit.md): Un formulaire de newsletter SvelteKit 3 dans le layout : une form action avec consentement et limite par IP, un use:enhance qui reste sur la page, et une route de webhook pour Kit.
- [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.
- [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.
