Newsletter-Anmeldeformular7 Min. Lesezeit
Newsletter-Anmeldeformular mit TanStack Start bauen
Ein Newsletter-Formular mit TanStack Start: eine Server-Funktion für Anmeldungen auf der Seite, eine Server-Route als Fallback ohne JavaScript und echte Anmeldungen, die an beehiiv gehen.
Schreib die Anmeldelogik einmal in einem Server-Modul und ruf sie von zwei Stellen auf: einer createServerFn-POST-Funktion, die das Formular auf der Seite aufruft, und einer Server-Route, an die dasselbe Formular sendet, wenn JavaScript noch nicht geladen ist. Beide prüfen die Einwilligungs-Checkbox und leiten die Anmeldung mit einem Secret Key an dein Formular-Backend weiter. Eine zweite Server-Route empfängt den signierten Webhook des Backends und legt das Abo in beehiiv an.
Der TanStack-Start-Tab der Hauptanleitung besteht aus einer Server-Funktion und einer Komponente. Das deckt Leser ab, deren JavaScript geladen ist. Diese Seite ergänzt, was eine breit geteilte Site braucht: einen Weg für den Leser, der vor der Hydration absendet, eine Ergebnisseite für diesen Weg und eine Webhook-Route, die echte Anmeldungen an beehiiv weitergibt. Das Produkt-Setup steht in der Doku-Anleitung für die Serverseite.
Was TanStack Start ändert
Eine Server-Funktion wird über einen RPC-Request aufgerufen, den der Client stellt, also tut ein Formular, das nur onSubmit kennt, nichts, bis das Bundle läuft. Die Doku von TanStack erwähnt eine .url-Eigenschaft, um ein Formular ohne JavaScript an eine Server-Funktion zu senden, dokumentiert aber nicht, wie diese Antwort gerendert wird. Eine Server-Route ist schlichter: Sie nimmt einen normalen Formular-POST und gibt eine normale Weiterleitung zurück. Also bekommt das Formular neben seinem onSubmit auch eine echte action:
- Der Leser sendet das Formular ab. Ob JavaScript geladen ist, entscheidet den Weg.
- Hydriert: onSubmit stoppt den Post und ruft die Server-Funktion mit den Daten des Formulars auf.
- Die Server-Funktion führt forwardSignup aus, das die Einwilligung prüft und an Ikiform sendet, und gibt das Ergebnis an die Komponente zurück.
- Nicht hydriert: Der Browser sendet an die Server-Route /api/newsletter, die dasselbe forwardSignup ausführt.
- Die Route antwortet mit 303 und einem Status oder Fehlercode, und die Seite /newsletter zeigt die passende Meldung.
Projekt-Setup
src/
server/newsletter.ts
components/NewsletterForm.tsx
routes/
__root.tsx
newsletter.tsx
api/newsletter.ts
api/ikiform.ts
.envServer-Code liest Secrets aus process.env. Gib ihnen nie ein VITE_-Präfix: Vite legt VITE_-Variablen ins Client-Bundle. 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
BEEHIIV_API_KEY=your_beehiiv_api_key
BEEHIIV_PUBLICATION_ID=pub_00000000-0000-0000-0000-000000000000Bauen
Das Anmeldemodul
export interface Signup {
email: string;
first_name?: string;
consent: boolean;
source: string;
_gotcha: string;
}
export type Outcome = { ok: true } | { ok: false; field: "email" | "consent" | "failed"; message: string };
const PLACEMENTS = new Set(["footer", "post", "newsletter-page"]);
export function readSignup(form: FormData): Signup {
const source = String(form.get("source") ?? "");
return {
email: String(form.get("email") ?? "").trim(),
first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
consent: form.get("consent") === "true",
source: PLACEMENTS.has(source) ? source : "footer",
_gotcha: String(form.get("_gotcha") ?? ""),
};
}
export async function forwardSignup(signup: Signup, origin: string | undefined): Promise<Outcome> {
if (!signup.consent) {
return { ok: false, field: "consent", message: "Tick the box so we know you want the emails." };
}
const 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 ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...signup, consent: true }),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return { ok: true };
const email = result?.error?.fields?.email?.[0];
if (email) return { ok: false, field: "email", message: email };
return { ok: false, field: "failed", message: result?.error?.message ?? "We couldn't reach the list. Try again." };
}readSignupmacht ausFormDataein einfaches Objekt. Beide Einstiegspunkte nutzen es, also steht ein Feldname an einer Stelle.sourcemuss eine von drei Platzierungen sein, sodass ein manipulierter Post keine neuen erfinden kann.- 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 ein
Outcomezurück, statt eine Exception zu werfen, sodass Komponente und Route ihn beide anzeigen können.
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Die Server-Funktion und das Formular
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
import { forwardSignup, type Outcome, readSignup } from "../server/newsletter";
const subscribe = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return readSignup(data);
})
.handler(({ data }) => forwardSignup(data, getRequestHeader("origin")));
export function NewsletterForm({ source = "footer" }: { source?: string }) {
const send = useServerFn(subscribe);
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try {
setOutcome(await send({ data }));
} catch {
setOutcome({ ok: false, field: "failed", message: "Couldn't subscribe. Try again." });
} finally {
setPending(false);
}
}
if (outcome?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
const error = outcome?.ok === false ? outcome : null;
return (
<form method="post" action="/api/newsletter" onSubmit={onSubmit}>
<label>
Email
<input name="email" type="email" required autoComplete="email" aria-invalid={error?.field === "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={error?.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 />
{error ? <p role="alert">{error.message}</p> : null}
<button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
</form>
);
}.validatorbekommt, was der Client gesendet hat. Dort aufFormDatazu prüfen, lässt einen manipulierten JSON-Aufruf scheitern, bevor der Handler läuft.getRequestHeaderkommt aus@tanstack/react-start/serverund liest den Request, der den RPC-Aufruf trug und den der Browser mit dem Origin deiner Seite gesendet hat.- Die
FormDatawerden vor dem erstenawaitgebaut, solangeevent.currentTargetnoch auf das Formular zeigt. useServerFnlässt den Aufruf Weiterleitungen und Fehlern über den Router folgen, was wichtig wird, wenn du späterredirect()aus dem Handler wirfst.
Die Route und Seite ohne JavaScript
import { createFileRoute } from "@tanstack/react-router";
import { forwardSignup, readSignup } from "../../server/newsletter";
export const Route = createFileRoute("/api/newsletter")({
server: {
handlers: {
POST: async ({ request }) => {
const signup = readSignup(await request.formData());
const outcome = await forwardSignup(signup, request.headers.get("origin") ?? undefined);
const target = new URL("/newsletter", request.url);
target.searchParams.set(outcome.ok ? "status" : "error", outcome.ok ? "subscribed" : outcome.field);
return Response.redirect(target, 303);
},
},
},
});import { createFileRoute } from "@tanstack/react-router";
import { NewsletterForm } from "../components/NewsletterForm";
const MESSAGES = {
consent: "Tick the box so we know you want the emails.",
email: "Check the email address and try again.",
failed: "We couldn't reach the list. Try again.",
};
export const Route = createFileRoute("/newsletter")({
validateSearch: (search: Record<string, unknown>) => ({
status: search.status === "subscribed" ? ("subscribed" as const) : undefined,
error: typeof search.error === "string" && search.error in MESSAGES ? (search.error as keyof typeof MESSAGES) : undefined,
}),
component: NewsletterPage,
});
function NewsletterPage() {
const { status, error } = Route.useSearch();
if (status === "subscribed") return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
return (
<main>
<h1>The newsletter</h1>
{error ? <p role="alert">{MESSAGES[error]}</p> : null}
<NewsletterForm source="newsletter-page" />
</main>
);
}Die Route leitet mit 303 weiter, sodass ein Neuladen der Ergebnisseite das Formular nicht erneut sendet. Sie übergibt einen Fehlercode, nie die Meldung von Ikiform, und validateSearch verwirft alles außerhalb der drei Codes, sodass niemand über einen Link eigenen Text auf deine Seite bringen kann.
Abonnenten an beehiiv weiterleiten
Diese Server-Route empfängt den signierten Webhook von Ikiform und legt das beehiiv-Abo mit double_opt_override: "on" an, sodass beehiiv die Bestätigung mailt. Sie liest den Body vor allem anderen mit request.text(), weil die Signatur genau die Bytes abdeckt. Die beehiiv-Anleitung ergänzt Vorname, Referrer und die Rate-Limit-Header von beehiiv.
import { createFileRoute } from "@tanstack/react-router";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export const Route = createFileRoute("/api/ikiform")({
server: {
handlers: {
POST: async ({ 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, consent, source } = event.data.fields;
if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });
const answer = await fetch(
`https://api.beehiiv.com/v2/publications/${process.env.BEEHIIV_PUBLICATION_ID}/subscriptions`,
{
method: "POST",
headers: { Authorization: `Bearer ${process.env.BEEHIIV_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
email,
double_opt_override: "on",
reactivate_existing: false,
utm_source: typeof source === "string" ? source : "website",
utm_medium: "signup_form",
}),
},
);
if (answer.status === 429) return new Response("beehiiv rate limit", { status: 503, headers: { "retry-after": "60" } });
if (!answer.ok) return new Response(`beehiiv answered ${answer.status}`, { status: 502 });
return new Response(null, { status: 204 });
},
},
},
});Füge https://your-site.com/api/ikiform unter Webhooks hinzu, wähle das Newsletter-Formular und kopiere das whsec_-Secret in IKIFORM_WEBHOOK_SECRET.
Honeypot und Menschlichkeitsprüfung
Der _gotcha-Input reist mit der Anmeldung, und Ikiform speichert einen ausgefüllten als Spam, mit Erfolgsantwort und ohne Webhook, sodass beehiiv ihn nie sieht. Der Secret Key überspringt die Turnstile-Prüfung von Ikiform. Kommen Bots an Honeypot und Pflicht-Checkbox vorbei, verifiziere ein Turnstile-Token in forwardSignup, wie die Turnstile-Anleitung zeigt, damit beide Einstiegspunkte es bekommen.
Rendering
Start rendert Routen standardmäßig auf dem Server, also steht das HTML des Formulars, samt action, in der ersten Antwort. Renderst du Seiten als statische Dateien vor, funktioniert das Formular dort trotzdem: Die Server-Funktion und beide Server-Routen laufen beim Host on demand.
Deployen
| Host | Was zu tun ist |
|---|---|
| Vercel | Füge Nitro hinzu: Installiere nitro und setze nitro() aus nitro/vite in die Vite-Plugins. Setze die vier Variablen unter Environment Variables. |
| Netlify | Füge @netlify/vite-plugin-tanstack-start hinzu und setze netlify() in die Vite-Plugins. Setze die Variablen in den Umgebungseinstellungen der Site. |
| Cloudflare | Füge @cloudflare/vite-plugin als erstes Plugin hinzu, mit einer wrangler.jsonc, die nodejs_compat aktiviert. Mit diesem Flag und einem aktuellen Compatibility Date stellen Workers Variablen und Secrets über process.env bereit. Nutze für die Webhook-URL eine eigene Domain; Ikiform lehnt workers.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 vom Dev-Server zu einer davon. Teste beide Wege: einmal normal und einmal mit in den Entwicklertools deaktiviertem JavaScript. Ikiform stellt keine Webhooks an localhost zu, also teste /api/ikiform über einen Tunnel mit HTTPS-URL.
Prüfen, ob es funktioniert
- Starte den Dev-Server und 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 erneut. Du landest auf
/newsletter?status=subscribed. - Sende mit weiterhin ausgeschaltetem JavaScript ab, mit entferntem
requiredund ohne Häkchen. Du landest auf/newsletter?error=consentmit der Einwilligungsmeldung. - Ö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 |
|---|---|
Der Secret Key ist in Produktion undefined | Der Host hat die Variable nicht bekommen, oder sie wurde in Code gelesen, der im Browser läuft. Lies Secrets nur in Server-Funktionen und Server-Routen. |
| Eine JSON-Fehlerseite nach dem Absenden ohne JavaScript | Die action des Formulars zeigt auf die Server-Funktion statt auf die Server-Route. Sende an /api/newsletter. |
402 plan_required bei lokalen Tests | Der Post erreichte Ikiform ohne localhost-Origin. Prüf, dass forwardSignup den Origin von beiden Aufrufern bekommt. |
Webhook-Zustellungen scheitern mit 400 | Das Secret passt nicht zu dem des Endpunkts, oder etwas hat den Body vor request.text() geparst. Lies zuerst den rohen Text. |
| 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
Die Doku von TanStack erwähnt eine .url-Eigenschaft, um ein Formular an eine Server-Funktion zu senden, aber eine Server-Route ist der einfachere Fallback: Sie nimmt einen einfachen Formular-POST und leitet weiter. Diese Seite nutzt beides, mit einem gemeinsamen Modul dahinter.
In reine Server-Umgebungsvariablen, die in Server-Funktionen und Server-Routen mit process.env gelesen werden. Variablen mit dem Präfix VITE_ landen im Browser-Bundle.
Ikiform beantwortet Spam mit demselben Erfolg wie eine echte Anmeldung. Sein Webhook wird nur für Anmeldungen gesendet, die kein Spam sind, also hält das Anlegen des Abos aus dem Webhook Bots von deiner Liste fern.
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.