Newsletter-Anmeldeformular8 Min. Lesezeit
Newsletter-Anmeldeformular mit Next.js bauen
Ein Newsletter-Formular im Footer mit Next.js: eine Server Action mit Einwilligungsprüfung und Turnstile, die an Ikiform sendet, plus ein Route Handler, der Leser zu Kit hinzufügt.
Setze ein Client-Formular in den Footer des Root-Layouts und richte es auf eine Server Action aus. Die Action prüft die Einwilligungs-Checkbox und ein Cloudflare-Turnstile-Token und leitet die Anmeldung dann mit einem Secret Key an dein Formular-Backend weiter. Ein separater Route Handler empfängt den signierten Webhook des Backends und fügt den Leser deinem E-Mail-Tool hinzu, sodass Spam nie deine Liste erreicht.
Der Next.js-Tab der Hauptanleitung hat eine Server Action und eine Formular-Komponente. Diese Seite baut das Ganze für eine echte Site: ein Formular im Footer jeder Seite, eine Menschlichkeitsprüfung, die auch funktioniert, wenn der Secret Key die von Ikiform überspringt, und einen Route Handler, der als gut bestätigte Anmeldungen an Kit weitergibt. Produkt-Setup und Keys stehen in der Next.js-Anleitung der Doku.
Was ein Footer-Formular in Next.js ändert
Ein Footer-Formular lebt im Root-Layout, rendert also auf jeder Seite, auch auf Seiten, die Next.js einmal beim Deploy baut. Daraus folgen drei Dinge:
| Frage | Was passiert | Was dieser Aufbau tut |
|---|---|---|
| Statische Seiten | Das Formular darf beim Rendern keine Cookies oder Header lesen, sonst wird jede Seite dynamisch | Das Formular liest beim Rendern nichts. Nur die Action greift auf den Request zu |
| Die Menschlichkeitsprüfung | Ein Post mit Secret Key überspringt Ikiforms Turnstile-Prüfung | Führt in der Action eine eigene Turnstile-Prüfung aus |
| Wer sie aufrufen kann | Eine Server Action ist ein öffentlicher POST-Endpunkt, auch ohne dein Formular erreichbar | Validiert Einwilligung und Token, bevor irgendetwas gesendet wird |
- Der Leser füllt das Footer-Formular aus. Es sendet an die Server Action auf der Seite, die er gerade liest.
- Die Action prüft die Einwilligungs-Checkbox und verifiziert dann das Turnstile-Token bei Cloudflare.
- Scheitert eine der Prüfungen, kommt das Formular mit der Meldung und der eingetippten E-Mail zurück. Nichts wird gesendet.
- Sonst sendet die Action die Anmeldung mit dem Secret Key und dem Origin des Browsers an Ikiform.
- Die Antwort von Ikiform wird zum Action-State, und das Formular wird durch die Zeile „Schau in deinen Posteingang“ ersetzt.
- Separat sendet Ikiform für jede Anmeldung, die kein Spam ist, einen signierten Webhook. Der Route Handler verifiziert ihn und fügt den Leser einem Kit-Formular hinzu, dessen Incentive-E-Mail die Bestätigung von Kit ist. Teste das einmal mit einer Adresse, die dir gehört; die Kit-Anleitung erklärt, warum.
Projekt-Setup
app/
layout.tsx
newsletter/
actions.ts
newsletter-form.tsx
submit-button.tsx
page.tsx
api/ikiform/route.ts
lib/
turnstile.ts
.env.localErstelle unter API keys einen Secret Key mit dem Scope submissions:write. Lege im Cloudflare-Dashboard ein Turnstile-Widget für deine Domain und localhost an. Nur der Site Key darf das Präfix NEXT_PUBLIC_ tragen; die anderen Werte müssen auf dem Server bleiben.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_turnstile_site_key
TURNSTILE_SECRET_KEY=your_turnstile_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
KIT_API_KEY=your_kit_v4_api_key
KIT_FORM_ID=1234567Füge die Felddefinition der Hauptanleitung im Tab Fields des Formulars ein, damit Ikiform die Pflicht-Checkbox consent durchsetzt, egal was ankommt.
Bauen
Die Turnstile-Prüfung
Cloudflares siteverify-Endpunkt nimmt das Secret, das Token und optional die IP des Besuchers. Ein Token ist 300 Sekunden gültig und kann einmal geprüft werden.
import "server-only";
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
export async function passesTurnstile(token: string, visitor?: string) {
if (!token) return false;
const body = new URLSearchParams({ secret: process.env.TURNSTILE_SECRET_KEY ?? "", response: token });
if (visitor) body.set("remoteip", visitor);
const response = await fetch(SITEVERIFY, { method: "POST", body }).catch(() => null);
const outcome = (await response?.json().catch(() => null)) as { success?: boolean } | null;
return outcome?.success === true;
}Die Server Action
"use server";
import { headers } from "next/headers";
import { passesTurnstile } from "@/lib/turnstile";
export interface SignupState {
status: "idle" | "subscribed" | "error";
field?: "email" | "consent" | "check";
message: string;
email: string;
firstName: string;
}
const PLACEMENTS = ["footer", "post", "newsletter-page"];
export async function subscribe(_previous: SignupState, formData: FormData): Promise<SignupState> {
const email = String(formData.get("email") ?? "").trim();
const firstName = String(formData.get("first_name") ?? "").trim().slice(0, 80);
const kept = { email, firstName };
if (formData.get("consent") !== "true") {
return { ...kept, status: "error", field: "consent", message: "Tick the box so we know you want the emails." };
}
const request = await headers();
const visitor = request.get("x-forwarded-for")?.split(",")[0]?.trim();
if (!(await passesTurnstile(String(formData.get("cf-turnstile-response") ?? ""), visitor))) {
return { ...kept, status: "error", field: "check", message: "The human check didn't pass. Try once more." };
}
const source = String(formData.get("source") ?? "");
let response: Response;
try {
response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
Origin: request.get("origin") ?? "",
},
body: JSON.stringify({
email,
first_name: firstName || undefined,
consent: true,
source: PLACEMENTS.includes(source) ? source : "footer",
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
});
} catch {
return { ...kept, status: "error", message: "We couldn't reach the list. Try again in a moment." };
}
const result = await response.json().catch(() => null);
if (result?.ok) {
return { status: "subscribed", message: "Almost done. Open the email we sent you and confirm your address.", email: "", firstName: "" };
}
const emailError = result?.error?.fields?.email?.[0];
if (emailError) return { ...kept, status: "error", field: "email", message: emailError };
return { ...kept, status: "error", message: result?.error?.message ?? "Couldn't subscribe. Try again." };
}- Die Einwilligung wird zuerst geprüft, weil das nichts kostet. Der Turnstile-Aufruf kommt danach.
sourcewird mit einer festen Liste abgeglichen. Ein manipulierter Post kann deinen Bericht über Platzierungen nicht mit Müll füllen.- Die
Origin-Zeile reicht den Origin des Browsers weiter, denn Ikiform erkennt kostenloselocalhost-Tests an diesem Header, und dasfetcheines Servers sendet keinen. - Jeder Fehler gibt die eingetippte E-Mail und den Namen zurück. React setzt ein Formular nach einer Action zurück, und
defaultValuestellt sie wieder her. - Der Honeypot wird unverändert weitergeleitet. Ikiform speichert einen ausgefüllten als Spam und antwortet mit Erfolg, sodass ein Bot nichts lernt.
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Das Formular und sein Pending-Zustand
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>;
}"use client";
import Script from "next/script";
import { useActionState, useEffect, useId, useRef } from "react";
import { type SignupState, subscribe } from "./actions";
import { SubmitButton } from "./submit-button";
declare global {
interface Window {
turnstile?: { render(box: HTMLElement, options: { sitekey: string }): string; reset(id: string): void };
}
}
const initial: SignupState = { status: "idle", message: "", email: "", firstName: "" };
export function NewsletterForm({ source }: { source: "footer" | "post" | "newsletter-page" }) {
const [state, action] = useActionState(subscribe, initial);
const id = useId();
const box = useRef<HTMLDivElement>(null);
const widget = useRef<string | null>(null);
function mountCheck() {
if (!box.current || !window.turnstile || widget.current) return;
widget.current = window.turnstile.render(box.current, {
sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "",
});
}
useEffect(mountCheck, []);
useEffect(() => {
if (state.status === "error" && widget.current) window.turnstile?.reset(widget.current);
}, [state]);
if (state.status === "subscribed") return <p role="status">{state.message}</p>;
return (
<form action={action}>
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" onLoad={mountCheck} />
<label htmlFor={`${id}-email`}>Email</label>
<input
id={`${id}-email`}
name="email"
type="email"
required
autoComplete="email"
defaultValue={state.email}
aria-invalid={state.field === "email"}
/>
<label htmlFor={`${id}-name`}>First name (optional)</label>
<input id={`${id}-name`} name="first_name" maxLength={80} autoComplete="given-name" defaultValue={state.firstName} />
<label>
<input name="consent" type="checkbox" value="true" required aria-invalid={state.field === "consent"} />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" value={source} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<div ref={box} />
{state.status === "error" ? <p role="alert">{state.message}</p> : null}
<SubmitButton />
</form>
);
}useFormStatusmuss in einem Kind des<form>gelesen werden, deshalb ist der Button eine eigene Komponente.useIdhält Input-IDs eindeutig, wenn das Footer-Formular und ein Formular auf der Seite/newslettergleichzeitig zu sehen sind.- Das Turnstile-Skript lädt mit
?render=explicit.rendersetzt das Widget ins Formular, das dann einen versteckten Inputcf-turnstile-responsemitsendet. Explizites Rendern funktioniert auch für Formulare, die nach einer clientseitigen Navigation gemountet werden. - Ein Token kann einmal geprüft werden, also wird das Widget nach jedem Fehler zurückgesetzt. Ohne das Zurücksetzen scheitert der zweite Versuch immer.
Ins Layout setzen
import { NewsletterForm } from "./newsletter/newsletter-form";
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>
{children}
<footer>
<p>One email a month about what we shipped. No tracking pixels.</p>
<NewsletterForm source="footer" />
</footer>
</body>
</html>
);
}Nutze source="post" am Ende von Artikeln und source="newsletter-page" auf einer eigenen Seite. LayoutProps ist der routenbewusste Typ, den Next.js für Layouts erzeugt.
Abonnenten an Kit weiterleiten
Der Route Handler unten empfängt den Webhook von Ikiform. Er liest den Body zuerst als Text, weil die Signatur genau die Bytes abdeckt. Füge seine URL unter Webhooks hinzu, wähle das Newsletter-Formular und kopiere das whsec_-Secret. Die Kit-Anleitung behandelt Tags und die Bedeutung jeder Antwort von Kit.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
function kit(path: string, body: Record<string, unknown>) {
return fetch(`https://api.kit.com/v4${path}`, {
method: "POST",
headers: {
"X-Kit-Api-Key": process.env.KIT_API_KEY ?? "",
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
});
}
export async function POST(request: Request) {
let event;
try {
event = await verifyWebhook({
payload: await request.text(),
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET ?? "",
});
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const { email, first_name, consent } = event.data.fields;
if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });
const created = await kit("/subscribers", {
email_address: email,
first_name: typeof first_name === "string" ? first_name : null,
state: "inactive",
});
const added = created.ok
? await kit(`/forms/${process.env.KIT_FORM_ID}/subscribers`, { email_address: email })
: created;
if (added.status === 429) return new Response("Kit rate limit", { status: 503, headers: { "retry-after": "60" } });
if (!added.ok) return new Response(`Kit answered ${added.status}`, { status: 502 });
return new Response(null, { status: 204 });
}- Hast du eine
proxy.ts, frühermiddleware.ts, die Besucher ohne Session umleitet, halte/api/ikiformaus ihremmatcherheraus. Sonst bekommt jede Zustellung deine Anmelde-Weiterleitung statt des Handlers. - Mit
trailingSlash: trueinnext.configleitet Next.js/api/ikiformauf/api/ikiform/um. Registriere die URL mit dem Schrägstrich. - Ein
503mitRetry-Afterlässt Ikiform warten und es erneut versuchen, und ein502bringt Kits Status ins Zustellungsprotokoll.
Rendering und Progressive Enhancement
Nichts im Formular liest beim Rendern den Request, also macht das Layout deine statischen Seiten nicht dynamisch. Die Action läuft pro Request, unabhängig vom Rendering-Modus der Seite. Weil die Action direkt an action geht, rendert React einen echten Formular-Post, und ein Leser, der absendet, bevor das JavaScript geladen ist, erreicht trotzdem die Action. Das Turnstile-Widget braucht allerdings JavaScript, also bekommt der Leser bei ausgeschalteten Skripten die Meldung der Menschlichkeitsprüfung statt einer Anmeldung. Passt dieser Kompromiss nicht zu deinen Lesern, streich die Prüfung und verlass dich auf den Honeypot und die Pflicht-Checkbox.
Deployen
| Host | Was zu tun ist |
|---|---|
| Vercel | Füge die sechs Variablen unter Environment Variables des Projekts hinzu. Richte den Ikiform-Webhook auf deine Produktionsdomain aus; Preview-Deployments können hinter Vercels Deployment Protection liegen, die den Webhook abweist. |
| Netlify | Netlify baut Next.js mit einem eigenen Adapter. Füge die Variablen in den Umgebungseinstellungen der Site hinzu, beschränkt auf Functions, und deploye neu. |
| Cloudflare | Betreibe Next.js über OpenNext auf Workers. Setze die Variablen in den Einstellungen des Workers und gib dem Worker eine eigene Domain: Ikiform lehnt workers.dev-URLs für Webhooks ab. |
Kostenlos auf localhost testen
Ein Workspace ohne Tarif nimmt 50 Testeinsendungen pro Monat von localhost an. Dank des weitergereichten Origin zählt next dev auf http://localhost:3000 dazu. Nutze die veröffentlichten Turnstile-Testkeys von Cloudflare in .env.local, damit die Prüfung auf deinem Rechner immer besteht. Webhooks erreichen localhost nicht; um den Route Handler auszuprobieren, mach ihn über einen Tunnel mit HTTPS-URL erreichbar oder nutze nach dem Deploy Send test im Dashboard.
Prüfen, ob es funktioniert
- Starte
next dev, scroll zum Footer und abonniere mit einer Adresse, die dir gehört. Die Zeile „Schau in deinen Posteingang“ ersetzt das Formular. - Öffne Submissions. Die Anmeldung hat
consentauftrueundsourceauffooter. - Entferne in den Entwicklertools
requiredvon der Checkbox und sende ohne Häkchen ab. Die Einwilligungsmeldung erscheint, und nichts wird gespeichert. - Blockiere
challenges.cloudflare.comin den Entwicklertools und sende ab. Die Meldung der Menschlichkeitsprüfung erscheint, und nichts wird gespeichert. - Wenn du einen Webhook hinzugefügt hast, öffne den Endpunkt unter Webhooks. Die Zustellung zeigt
succeededmit der Antwort deines Servers. - Öffne Kit. Der Abonnent ist als inactive da, und die Bestätigungs-E-Mail von Kit liegt in deinem Posteingang.
- Sende einen Post mit ausgefülltem Honeypot,
_gotcha=bot. Er bekommt trotzdem eine Erfolgsantwort, aber die Einsendung wird als Spam gespeichert und löst weder E-Mail noch Webhook aus.
Stolperfallen
| Problem | Was zu tun ist |
|---|---|
402 plan_required bei jedem lokalen Test | Der Post erreichte Ikiform ohne localhost-Origin. Prüf die Origin-Zeile in der Action und öffne die App unter localhost, nicht unter deiner Netzwerkadresse. |
| Die Menschlichkeitsprüfung scheitert bei jedem Versuch | Site Key und Secret stammen von unterschiedlichen Widgets, oder das Widget ist nicht für diesen Hostnamen eingerichtet. Testkeys bestehen nur mit dem Test-Secret. |
| Jede statische Seite wurde nach dem Hinzufügen des Footers dynamisch | Etwas im Formular oder Layout ruft beim Rendern headers() oder cookies() auf. Lies den Request nur in der Action. |
| Failed to find Server Action, direkt nach einem Deploy | Ein Tab läuft noch mit dem vorherigen Build, dessen Action-IDs sich geändert haben. Der Leser lädt neu, und es funktioniert. |
Webhook-Zustellungen zeigen 307 oder 308 | Eine Proxy-Weiterleitung oder die Einstellung für den Schrägstrich am Ende hat /api/ikiform erwischt. Nimm es aus dem Matcher heraus oder registriere die URL mit dem Schrägstrich. |
| 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
Nein. Ikiform beantwortet Spam mit demselben Erfolg wie eine echte Anmeldung, also kann die Action sie nicht unterscheiden. Lass den Webhook von Ikiform das E-Mail-Tool steuern: Er wird nur für Anmeldungen gesendet, die kein Spam sind.
Nicht mit einem Secret Key, der sie überspringt. Führe entweder Turnstile selbst in der Action aus, wie auf dieser Seite, oder sende mit @ikiform/sdk aus dem Browser, was Ikiforms Prüfung, erlaubte Domains und Limits pro Besucher beibehält.
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.