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

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/newsletter-signup/nuxt

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

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

Diagramm: Wie die Newsletter-Route in Nuxt jede Art von Aufrufer beantwortet

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

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

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](https://ikiform.com/de/guides/newsletter-signup.md#fields) im Tab **Fields** des Formulars ein.

## Bauen

### Die 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` 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](https://ikiform.com/de/docs/rate-limits.md) Relaxed oder Custom, und halte `IKIFORM_SECRET_KEY` in reinen Server-Umgebungsvariablen.

### Die Formular-Komponente

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

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

```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](https://ikiform.com/de/guides/newsletter-signup/beehiiv.md) ergänzt den Vornamen und die eigenen Rate-Limit-Header von 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);
});
```

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

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

| 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

### Kann ein Nuxt-Formular ohne JavaScript funktionieren?

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.

### Unterstützt nuxt generate Server-Routen für ein Newsletter-Formular?

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.

### Warum Abonnenten per Webhook statt aus der Server-Route an beehiiv schicken?

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.

### Ist Ikiform kostenlos?

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.

## Weitere Anleitungen

- [Newsletter-Anmeldeformular](https://ikiform.com/de/guides/newsletter-signup.md): Baue ein Newsletter-Anmeldeformular mit eigenem Markup, speichere die Einwilligung, blocke Bot-Anmeldungen und gib jeden Abonnenten an Kit, MailerLite, beehiiv oder ein anderes Tool weiter.
- [beehiiv](https://ikiform.com/de/guides/newsletter-signup/beehiiv.md): Verbinde dein eigenes Anmeldeformular mit beehiiv: Ein Relay legt jedes Abo mit aktivem Double Opt-in an, mit der Platzierung als UTM-Quelle und der Seite als Referrer.
- [Double-Opt-in-Verfahren](https://ikiform.com/de/guides/newsletter-signup/double-opt-in.md): Ikiform sendet keine Bestätigungs-E-Mails, also läuft Double-Opt-in in deinem E-Mail-Tool: Ein Relay legt jede Anmeldung als unbestätigt an, und das Tool sendet den Link.
- [Newsletter-Formular mit SvelteKit](https://ikiform.com/de/guides/newsletter-signup/sveltekit.md): Ein Newsletter-Formular mit SvelteKit 3 im Layout: eine Form Action mit Einwilligung und IP-Limit, use:enhance, das auf der Seite bleibt, und eine Webhook-Route für Kit.
- [Statische Site](https://ikiform.com/de/guides/newsletter-signup/static-site.md): Füge einer statischen Site mit Hugo, Jekyll, Eleventy oder Astro ein Newsletter-Formular hinzu, halte den API-Key des E-Mail-Tools aus der Seite heraus und bestätige im Tool.
- [Kit (ConvertKit)](https://ikiform.com/de/guides/newsletter-signup/kit.md): Verbinde dein eigenes Newsletter-Formular mit Kit, über ein Relay, das den Abonnenten anlegt, ihn für die Incentive-E-Mail einem Kit-Formular hinzufügt und die Platzierung taggt.
