Newsletter-Anmeldeformular7 Min. Lesezeit
Newsletter-Anmeldeformular mit Nuxt bauen
Ein Newsletter-Formular mit Nuxt, das mit oder ohne JavaScript funktioniert: Eine Server-Route mit Turnstile-Prüfung sendet an Ikiform, und eine Nitro-Route beliefert beehiiv.
Gib dem Formular eine echte action, die auf eine Nuxt-Server-Route zeigt, und erweitere es mit $fetch, damit Leser mit JavaScript auf der Seite bleiben. Die Route prüft die Einwilligungs-Checkbox und ein Turnstile-Token aus @nuxtjs/turnstile, leitet die Anmeldung mit einem Secret Key an dein Formular-Backend weiter und antwortet je nach Aufrufer mit JSON oder einer Weiterleitung. Eine zweite Route macht aus dem signierten Webhook des Backends ein beehiiv-Abo.
Der Nuxt-Tab der Hauptanleitung sendet mit $fetch und funktioniert, sobald Vue hydriert hat. Diese Seite lässt dasselbe Formular auch davor funktionieren, ergänzt eine Menschlichkeitsprüfung und schickt echte Anmeldungen weiter an beehiiv. Das Produkt-Setup steht in der Nuxt-Anleitung der Doku.
Eine Route, zwei Arten von Aufrufern
Eine Nuxt-Server-Route weiß nicht, ob ein Browser-Formular oder $fetch sie aufgerufen hat, also entscheidet sie anhand des Accept-Headers. $fetch aus der Komponente fragt nach JSON und bekommt JSON oder einen Fehler mit Grund. Ein einfacher Formular-Post, vor der Hydration oder mit ausgeschalteten Skripten, fragt nach HTML, also antwortet die Route mit 303 an /newsletter mit einem Ergebniscode:
- Das Formular sendet an /api/newsletter, nach der Hydration über $fetch, davor als einfacher Formular-Post.
- Die Route prüft Einwilligung und Turnstile-Token und leitet die Anmeldung dann an Ikiform weiter.
- Sie liest den Accept-Header, um zu entscheiden, wie sie antwortet.
- $fetch hat nach JSON gefragt: Erfolg gibt JSON zurück, und ein Problem wirft einen Fehler mit einem Grund, den die Komponente anzeigt.
- Ein einfacher Formular-Post bekommt eine 303-Weiterleitung an /newsletter mit einem Ergebniscode, und die Seite macht aus dem Code eine Meldung.
Projekt-Setup
Installiere das Turnstile-Modul mit npx nuxi@latest module add turnstile und dann:
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_keyJeder Key unter runtimeConfig wird zur Laufzeit von der passenden NUXT_-Variable überschrieben, also enthält die Konfiguration leere Strings und der Host die Werte. Nichts außerhalb von runtimeConfig.public erreicht den Browser. Der Turnstile Site Key ist bewusst öffentlich. Erstelle den Ikiform-Key unter API keys mit dem Scope submissions:write und füge die Felddefinition der Hauptanleitung im Tab Fields des Formulars ein.
Bauen
Die 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.");
});readBodyparst sowohl JSON von$fetchals auch den URL-kodierten Body eines einfachen Formular-Posts. Deshalb wird die Einwilligung alstrueoder"true"akzeptiert.verifyTurnstileTokenkommt aus dem Turnstile-Modul und liestruntimeConfig.turnstile.secretKey. Tokens sind einmalig nutzbar und laufen nach fünf Minuten ab.- Die
Origin-Zeile reicht den Origin des Browsers weiter, denn Ikiform erkennt kostenloselocalhost-Tests an diesem Header, und dasfetcheines Servers sendet keinen. useRuntimeConfig(event)holt die Werte mit angewendetenNUXT_-Überschreibungen zur Laufzeit, was Nuxt in Server-Routen empfiehlt.
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Die Formular-Komponente
<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>- Das Formular behält
actionundmethod, sendet also auf dem einfachen Weg, bis@submit.preventnach der Hydration übernimmt. <NuxtTurnstile>rendert das Widget und legt sein Token intoken. Nach jedem Fehler ist das Token verbraucht, also fordertreset()ein neues an.- Die Felder werden vor dem ersten
awaitausFormDatagelesen, solangeevent.currentTargetnoch auf das Formular zeigt.
Die Seite /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 braucht JavaScript, also bekommt ein Leser mit ausgeschalteten Skripten die Meldung check. Passt dieser Kompromiss nicht zu deinen Lesern, entferne die Turnstile-Zeilen und verlass dich auf den Honeypot und die Pflicht-Checkbox.
Abonnenten an beehiiv weiterleiten
Diese Route liegt in server/routes, hat also kein /api-Präfix. Sie liest den Body mit readRawBody, bevor irgendetwas ihn parst, weil die Signatur genau die Bytes abdeckt, und legt das Abo mit double_opt_override: "on" an, sodass beehiiv die Bestätigung sendet. Die beehiiv-Anleitung ergänzt den Vornamen und die eigenen Rate-Limit-Header von 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);
});Füge https://your-site.com/hooks/ikiform unter Webhooks hinzu, wähle das Newsletter-Formular und kopiere das whsec_-Secret in NUXT_IKIFORM_WEBHOOK_SECRET.
Honeypot und Menschlichkeitsprüfung
Der _gotcha-Input wird weitergeleitet, und Ikiform speichert einen ausgefüllten als Spam, mit Erfolgsantwort und ohne Webhook, sodass beehiiv ihn nie sieht. Der Secret Key überspringt Ikiforms eigene Turnstile-Prüfung, deshalb führt die Route eine mit deinem eigenen Widget aus.
Rendering: hybrid, nicht generate
nuxt generate schreibt eine statische Site ohne Server, also existieren /api/newsletter und /hooks/ikiform danach nicht. Baue mit nuxt build und rendere die Seiten, die statisch sein können, über routeRules vor, wie es die Konfiguration oben für /blog/** tut. Vorgerenderte Seiten behalten das Formular, und sein Post erreicht weiterhin die Server-Route.
Deployen
| Host | Was zu tun ist |
|---|---|
| Vercel | Nitro erkennt Vercel und baut Server-Routen als Functions. Füge die NUXT_-Variablen unter Environment Variables hinzu. |
| Netlify | Nitro erkennt Netlify auf dieselbe Weise. Füge die Variablen in den Umgebungseinstellungen der Site hinzu. |
| Cloudflare | Baue mit nuxt build, nicht mit nuxt generate, und setze die NUXT_-Variablen als Secrets im Projekt. Nutze für die Webhook-URL eine eigene Domain; Ikiform lehnt workers.dev und pages.dev ab. |
Kostenlos auf localhost testen
Ein Workspace ohne Tarif nimmt 50 Testeinsendungen pro Monat von localhost an, und der weitergereichte Origin macht jede Anmeldung aus nuxt dev zu einer davon. Nutze lokal die veröffentlichten Turnstile-Testkeys von Cloudflare, damit die Prüfung immer besteht. Ikiform stellt keine Webhooks an localhost zu; mach den Dev-Server über einen Tunnel mit HTTPS-URL erreichbar, um /hooks/ikiform zu testen.
Prüfen, ob es funktioniert
- Starte
nuxt devund abonniere über den Footer. Das Formular wird durch die Zeile „Schau in deinen Posteingang“ ersetzt, und die URL ändert sich nicht. - Deaktiviere JavaScript und abonniere. Du landest auf
/newsletter?result=check, weil das Widget nicht laufen konnte. - Entferne mit eingeschaltetem JavaScript
requiredvon der Checkbox und sende ohne Häkchen ab. Die Einwilligungsmeldung erscheint, und nichts wird gespeichert. - Öffne Submissions im Dashboard. Die neue Einsendung ist da, mit jedem Feld, das du gesendet hast.
- Wenn du einen Webhook hinzugefügt hast, öffne den Endpunkt unter Webhooks. Die Zustellung zeigt
succeededmit der Antwort deines Servers. - Öffne beehiiv. Das Abo ist da, ausstehend bis zu deiner Bestätigung, und die Bestätigungs-E-Mail liegt in deinem Posteingang.
Stolperfallen
| Problem | Was zu tun ist |
|---|---|
404 auf /api/newsletter in Produktion | Die Site wurde mit nuxt generate gebaut, das keinen Server hat. Baue mit nuxt build und rendere Seiten über routeRules vor. |
| Der Secret Key ist zur Laufzeit leer | Der Name der Variable passt nicht zum Konfigurations-Key. ikiformSecretKey braucht NUXT_IKIFORM_SECRET_KEY. |
| Die Menschlichkeitsprüfung scheitert bei jedem zweiten Versuch | Das Token wurde wiederverwendet. Ruf nach jedem Fehler reset() am Widget auf, wie es die Komponente tut. |
402 plan_required bei lokalen Tests | Der Post hatte keinen localhost-Origin. Prüf die Origin-Zeile und öffne die Site unter localhost. |
| Die Einsendung ist im Dashboard, aber keine E-Mail und kein Webhook kam an | Sie wurde als Spam gespeichert. Spam löst nichts aus. Öffne sie unter dem Filter Spam, um die Gründe zu sehen, und markiere sie als kein Spam, um ihre E-Mail und Webhooks zu senden. |
Fragen
Ja, wenn es eine echte action und method behält. Richte es auf eine Server-Route aus, die bei einfachen Formular-Posts weiterleitet und für $fetch JSON zurückgibt, wie auf dieser Seite.
Nein. nuxt generate gibt nur statische Dateien aus, also fehlen Server-Routen. Nutze nuxt build mit routeRules, um die statischen Seiten vorzurendern, oder sende das Formular direkt aus dem Browser an Ikiform.
Die Server-Route sieht nur die Antwort von Ikiform, und Spam bekommt denselben Erfolg wie eine echte Anmeldung. beehiiv sollte erst nach der Spamprüfung von einem Leser erfahren, und genau dann feuert der Webhook.
Nein, es gibt keinen kostenlosen Tarif. Testen ist kostenlos: Du kannst 50 Testeinsendungen pro Monat von localhost senden, bevor du zahlst. Für den Livebetrieb startet Pro bei $6/Monat für 500 Einsendungen, oder du zahlst einmalig 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.