Newsletter-Anmeldeformular

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.

Preet SutharGründer, Ikiform

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:

Wie ein TanStack-Start-Formular mit oder ohne JavaScript denselben Anmeldecode erreichtNewsletterFormaction="/api/newsletter"hydriert?onSubmitangehängtServer-FunktionRPC,gleicheSeiteforwardSignup()Einwilligung,dannIkiformErgebnisan OrtundStellegerendert/api/newsletterServer-Route,Formular-POST303 an/newsletter?statusoder?errorabsendenjaDatenErgebnisnoch nichtWie ein TanStack-Start-Formular mit oder ohne JavaScript denselben Anmeldecode erreichtNewsletterFormaction="/api/newsletter"hydriert?onSubmitangehängtServer-FunktionRPC, gleiche SeiteforwardSignup()Einwilligung, dannIkiformErgebnisan Ort und Stellegerendert/api/newsletterServer-Route,Formular-POST303 an/newsletter?status oder?errorabsendenjaDatenErgebnisnoch nicht
  1. Der Leser sendet das Formular ab. Ob JavaScript geladen ist, entscheidet den Weg.
  2. Hydriert: onSubmit stoppt den Post und ruft die Server-Funktion mit den Daten des Formulars auf.
  3. 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.
  4. Nicht hydriert: Der Browser sendet an die Server-Route /api/newsletter, die dasselbe forwardSignup ausführt.
  5. Die Route antwortet mit 303 und einem Status oder Fehlercode, und die Seite /newsletter zeigt die passende Meldung.
forwardSignup ist der einzige Code, der mit Ikiform spricht, also validieren und melden beide Wege Fehler auf dieselbe Weise.

Projekt-Setup

Projektstruktur
src/
  server/newsletter.ts
  components/NewsletterForm.tsx
  routes/
    __root.tsx
    newsletter.tsx
    api/newsletter.ts
    api/ikiform.ts
.env

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

.env
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-000000000000

Bauen

Das Anmeldemodul

src/server/newsletter.ts
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." };
}
  • readSignup macht aus FormData ein einfaches Objekt. Beide Einstiegspunkte nutzen es, also steht ein Feldname an einer Stelle.
  • source muss 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 kostenlose localhost-Tests an diesem Header, und das fetch eines Servers sendet keinen.
  • Jeder Fehler gibt ein Outcome zurück, statt eine Exception zu werfen, sodass Komponente und Route ihn beide anzeigen können.
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 Server-Funktion und das Formular

src/components/NewsletterForm.tsx
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>
  );
}
  • .validator bekommt, was der Client gesendet hat. Dort auf FormData zu prüfen, lässt einen manipulierten JSON-Aufruf scheitern, bevor der Handler läuft.
  • getRequestHeader kommt aus @tanstack/react-start/server und liest den Request, der den RPC-Aufruf trug und den der Browser mit dem Origin deiner Seite gesendet hat.
  • Die FormData werden vor dem ersten await gebaut, solange event.currentTarget noch auf das Formular zeigt.
  • useServerFn lässt den Aufruf Weiterleitungen und Fehlern über den Router folgen, was wichtig wird, wenn du später redirect() aus dem Handler wirfst.

Die Route und Seite ohne JavaScript

src/routes/api/newsletter.ts
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);
      },
    },
  },
});
src/routes/newsletter.tsx
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.

src/routes/api/ikiform.ts
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

HostWas zu tun ist
VercelFüge Nitro hinzu: Installiere nitro und setze nitro() aus nitro/vite in die Vite-Plugins. Setze die vier Variablen unter Environment Variables.
NetlifyFüge @netlify/vite-plugin-tanstack-start hinzu und setze netlify() in die Vite-Plugins. Setze die Variablen in den Umgebungseinstellungen der Site.
CloudflareFü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

  1. 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.
  2. Deaktiviere JavaScript und abonniere erneut. Du landest auf /newsletter?status=subscribed.
  3. Sende mit weiterhin ausgeschaltetem JavaScript ab, mit entferntem required und ohne Häkchen. Du landest auf /newsletter?error=consent mit der Einwilligungsmeldung.
  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
Der Secret Key ist in Produktion undefinedDer 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 JavaScriptDie action des Formulars zeigt auf die Server-Funktion statt auf die Server-Route. Sende an /api/newsletter.
402 plan_required bei lokalen TestsDer Post erreichte Ikiform ohne localhost-Origin. Prüf, dass forwardSignup den Origin von beiden Aufrufern bekommt.
Webhook-Zustellungen scheitern mit 400Das 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 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.