Formulaire d'inscription à la newsletter8 min de lecture
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.
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 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.
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 :
- Le formulaire envoie à /api/newsletter, via $fetch une fois hydraté ou par un envoi de formulaire classique avant cela.
- La route vérifie le consentement et le jeton Turnstile, puis transmet l'inscription à Ikiform.
- Elle lit l'en-tête Accept pour décider comment répondre.
- $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.
- 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.
Mise en place du projet
Installez le module Turnstile avec npx nuxi@latest module add turnstile, puis :
app/
components/NewsletterForm.vue
pages/newsletter.vue
server/
api/newsletter.post.ts
routes/hooks/ikiform.post.ts
nuxt.config.ts
.envexport default defineNuxtConfig({
modules: ["@nuxtjs/turnstile"],
turnstile: { siteKey: "your_turnstile_site_key" },
runtimeConfig: {
ikiformSecretKey: "",
ikiformWebhookSecret: "",
beehiivApiKey: "",
beehiivPublicationId: "",
turnstile: { secretKey: "" },
},
routeRules: {
"/blog/**": { prerender: true },
},
});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_keyChaque 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 dans l'onglet Fields du formulaire.
La construire
La server route
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.");
});readBodyanalyse à la fois le JSON de$fetchet le corps URL-encoded d'un envoi de formulaire classique. C'est pourquoi le consentement est accepté sous la formetrueou"true".verifyTurnstileTokenvient du module Turnstile et litruntimeConfig.turnstile.secretKey. Les jetons sont à usage unique et expirent au bout de cinq minutes.- 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. useRuntimeConfig(event)récupère les valeurs avec les remplacementsNUXT_appliqués à l'exécution, ce que Nuxt recommande dans les routes serveur.
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Le composant formulaire
<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
actionetmethod, donc il envoie de façon classique jusqu'à ce que@submit.preventprenne le relais après l'hydratation. <NuxtTurnstile>affiche le widget et place son jeton danstoken. Après toute erreur, le jeton est consommé, doncreset()en demande un nouveau.- Les champs sont lus depuis
FormDataavant le premierawait, tant queevent.currentTargetpointe encore vers le formulaire.
La page /newsletter
<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 ajoute le prénom et les en-têtes de limite de débit de beehiiv.
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
- Lancez
nuxt devet 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. - Désactivez JavaScript et abonnez-vous. Vous arrivez sur
/newsletter?result=check, car le widget n'a pas pu s'exécuter. - Avec JavaScript activé, retirez
requiredde la case à cocher et envoyez-la décochée. Le message de consentement apparaît et rien n'est enregistré. - 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 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
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.
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.
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.
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.