Newsletter-Anmeldeformular

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.

Preet SutharGründer, Ikiform

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:

Wie die Newsletter-Route in Nuxt jede Art von Aufrufer beantwortetNewsletterFormaction="/api/newsletter"newsletter.post.tsEinwilligung,Turnstile,IkiformAccept:JSON?wergefragthatJSONoderFehler$fetch,gleicheSeite303/newsletter?result=...newsletter.vuezeigtdieMeldung$fetch oder Formular-POSTErgebnisjaneinWie die Newsletter-Route in Nuxt jede Art von Aufrufer beantwortetNewsletterFormaction="/api/newsletter"newsletter.post.tsEinwilligung,Turnstile, IkiformAccept:JSON?wer gefragt hatJSON oder Fehler$fetch, gleiche Seite303/newsletter?result=...newsletter.vuezeigt dieMeldung$fetch oder Formular-POSTErgebnisjanein
  1. Das Formular sendet an /api/newsletter, nach der Hydration über $fetch, davor als einfacher Formular-Post.
  2. Die Route prüft Einwilligung und Turnstile-Token und leitet die Anmeldung dann an Ikiform weiter.
  3. Sie liest den Accept-Header, um zu entscheiden, wie sie antwortet.
  4. $fetch hat nach JSON gefragt: Erfolg gibt JSON zurück, und ein Problem wirft einen Fehler mit einem Grund, den die Komponente anzeigt.
  5. Ein einfacher Formular-Post bekommt eine 303-Weiterleitung an /newsletter mit einem Ergebniscode, und die Seite macht aus dem Code eine Meldung.
Die Weiterleitung trägt einen Code, nie eine Meldung, sodass ein manipulierter Link keinen eigenen Text auf deine Seite bringen kann.

Projekt-Setup

Installiere das Turnstile-Modul mit npx nuxi@latest module add turnstile und dann:

Projektstruktur
app/
  components/NewsletterForm.vue
  pages/newsletter.vue
server/
  api/newsletter.post.ts
  routes/hooks/ikiform.post.ts
nuxt.config.ts
.env
nuxt.config.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
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

Jeder 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

server/api/newsletter.post.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 parst sowohl JSON von $fetch als auch den URL-kodierten Body eines einfachen Formular-Posts. Deshalb wird die Einwilligung als true oder "true" akzeptiert.
  • verifyTurnstileToken kommt aus dem Turnstile-Modul und liest runtimeConfig.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 kostenlose localhost-Tests an diesem Header, und das fetch eines Servers sendet keinen.
  • useRuntimeConfig(event) holt die Werte mit angewendeten NUXT_-Überschreibungen zur Laufzeit, was Nuxt in Server-Routen empfiehlt.
Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform Allowed domains und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das Rate-Limit Relaxed oder Custom, und halte IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.

Die Formular-Komponente

app/components/NewsletterForm.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>
  • Das Formular behält action und method, sendet also auf dem einfachen Weg, bis @submit.prevent nach der Hydration übernimmt.
  • <NuxtTurnstile> rendert das Widget und legt sein Token in token. Nach jedem Fehler ist das Token verbraucht, also fordert reset() ein neues an.
  • Die Felder werden vor dem ersten await aus FormData gelesen, solange event.currentTarget noch auf das Formular zeigt.

Die Seite /newsletter

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

server/routes/hooks/ikiform.post.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);
});

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

HostWas zu tun ist
VercelNitro erkennt Vercel und baut Server-Routen als Functions. Füge die NUXT_-Variablen unter Environment Variables hinzu.
NetlifyNitro erkennt Netlify auf dieselbe Weise. Füge die Variablen in den Umgebungseinstellungen der Site hinzu.
CloudflareBaue 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

  1. Starte nuxt dev und abonniere über den Footer. Das Formular wird durch die Zeile „Schau in deinen Posteingang“ ersetzt, und die URL ändert sich nicht.
  2. Deaktiviere JavaScript und abonniere. Du landest auf /newsletter?result=check, weil das Widget nicht laufen konnte.
  3. Entferne mit eingeschaltetem JavaScript required von der Checkbox und sende ohne Häkchen ab. Die Einwilligungsmeldung erscheint, und nichts wird gespeichert.
  4. Öffne Submissions im Dashboard. Die neue Einsendung ist da, mit jedem Feld, das du gesendet hast.
  5. Wenn du einen Webhook hinzugefügt hast, öffne den Endpunkt unter Webhooks. Die Zustellung zeigt succeeded mit der Antwort deines Servers.
  6. Öffne beehiiv. Das Abo ist da, ausstehend bis zu deiner Bestätigung, und die Bestätigungs-E-Mail liegt in deinem Posteingang.

Stolperfallen

ProblemWas zu tun ist
404 auf /api/newsletter in ProduktionDie 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 leerDer Name der Variable passt nicht zum Konfigurations-Key. ikiformSecretKey braucht NUXT_IKIFORM_SECRET_KEY.
Die Menschlichkeitsprüfung scheitert bei jedem zweiten VersuchDas Token wurde wiederverwendet. Ruf nach jedem Fehler reset() am Widget auf, wie es die Komponente tut.
402 plan_required bei lokalen TestsDer 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 anSie 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

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.