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

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

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

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

Diagramm: Wie ein TanStack-Start-Formular mit oder ohne JavaScript denselben Anmeldecode erreicht

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

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

`.env`:

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

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

### Die Server-Funktion und das Formular

`src/components/NewsletterForm.tsx`:

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

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

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

`src/routes/api/ikiform.ts`:

```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](https://ikiform.com/de/guides/spam-protection/cloudflare-turnstile.md) 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

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

| 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

### Funktionieren TanStack-Start-Server-Funktionen ohne JavaScript?

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.

### Wohin gehören Secrets in TanStack Start?

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.

### Warum den Abonnenten nicht in der Server-Funktion zu beehiiv hinzufügen?

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.

### 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 React Router](https://ikiform.com/de/guides/newsletter-signup/react-router.md): Ein Newsletter-Formular im Footer mit React Router: useFetcher sendet an eine Route /newsletter, die auch ohne JavaScript funktioniert, und eine Resource Route beliefert MailerLite.
- [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.
