# Newsletter-Anmeldeformular mit React Router bauen

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

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

Leg die Action auf eine echte Route `/newsletter` mit eigener Seite und sende aus einem Footer-Formular mit `useFetcher` daran. Mit JavaScript sendet der Fetcher im Hintergrund, und der Leser bleibt auf dem Artikel; ohne ist dasselbe Formular ein einfacher POST, der mit dem Ergebnis auf der Seite `/newsletter` landet. Die Action prüft die Einwilligung und leitet die Anmeldung an dein Formular-Backend weiter, und eine Resource Route macht aus dessen signiertem Webhook einen MailerLite-Abonnenten.

Der [React-Router-Tab der Hauptanleitung](https://ikiform.com/de/guides/newsletter-signup.md#form) sendet mit `useFetcher` an eine Resource Route. Das funktioniert, sobald JavaScript geladen ist. Diese Seite regelt, was davor passiert, verhindert, dass eine Anmeldung jeden Loader der Seite neu lädt, und ergänzt die Webhook-Route für MailerLite. Das Produkt-Setup steht in der [Doku-Anleitung für die Serverseite](https://ikiform.com/de/docs/guides/server.md).

## Warum die Action eine Seite braucht

Die Doku von React Router beschreibt `fetcher.Form` als normales HTML-Formular, bis JavaScript lädt. Ein Absenden in diesem Moment ist ein Dokument-POST an die `action`-URL des Formulars, und der Browser zeigt, was diese Route rendert. Eine Resource Route rendert nichts; sie gibt ihre Daten zurück. Der Leser, der früh klickt, landet also auf rohem JSON. Gib der Action eine Route mit Default-Komponente, und derselbe POST landet auf einer Seite:

Diagramm: Was ein Leser ohne JavaScript nach dem Absenden des Footer-Formulars sieht

1. Vor der Hydration ist fetcher.Form ein einfaches Formular, also sendet der Browser an /newsletter und navigiert dorthin.
2. Ist /newsletter eine Resource Route, zeigt der Browser das JSON der Action als Seite.
3. Hat /newsletter eine Default-Komponente, führt React Router die Action aus und rendert dann die Seite mit dem Ergebnis als actionData.
4. Sobald JavaScript geladen ist, verhalten sich beide Versionen gleich: Der Fetcher sendet im Hintergrund, und der Leser bleibt, wo er ist.

Die Lösung ist ein Default-Export. Action und Footer-Formular bleiben gleich.

## Projekt-Setup

Das nutzt den Framework-Modus mit aktiviertem Server-Rendering, `ssr: true` in `react-router.config.ts`, was der Standard ist.

`Projektstruktur`:

```text
app/
  root.tsx
  routes.ts
  routes/
    home.tsx
    newsletter.tsx
    api.ikiform.ts
  components/newsletter-form.tsx
react-router.config.ts
.env
```

`app/routes.ts`:

```ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("newsletter", "routes/newsletter.tsx"),
  route("api/ikiform", "routes/api.ikiform.ts"),
] satisfies RouteConfig;
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789
```

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 Route /newsletter

`app/routes/newsletter.tsx`:

```tsx
import { data } from "react-router";
import { NewsletterForm } from "~/components/newsletter-form";
import type { Route } from "./+types/newsletter";

const PLACEMENTS = ["footer", "post", "newsletter-page"];

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  if (form.get("consent") !== "true") {
    return data(
      { ok: false as const, field: "consent", message: "Tick the box so we know you want the emails." },
      { status: 422 },
    );
  }

  const source = String(form.get("source") ?? "");
  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: request.headers.get("origin") ?? "",
    },
    body: JSON.stringify({
      email: String(form.get("email") ?? "").trim(),
      first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
      consent: true,
      source: PLACEMENTS.includes(source) ? source : "footer",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { ok: true as const };
  const email = result?.error?.fields?.email?.[0];
  return data(
    {
      ok: false as const,
      field: email ? "email" : "form",
      message: String(email ?? result?.error?.message ?? "We couldn't reach the list. Try again."),
    },
    { status: response?.status ?? 502 },
  );
}

export function meta() {
  return [{ title: "Newsletter" }];
}

export default function Newsletter({ actionData }: Route.ComponentProps) {
  return (
    <main>
      <h1>The monthly newsletter</h1>
      <NewsletterForm source="newsletter-page" initial={actionData} />
    </main>
  );
}
```

- Fehler werden mit `data()` und einem `4xx`-Status zurückgegeben, nicht geworfen, sodass sie das Formular erreichen statt der Error Boundary.
- Die Einwilligungsprüfung läuft vor dem Netzwerkaufruf. Eine fehlende Checkbox kostet nichts.
- 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.
- Die Default-Komponente rendert dasselbe Formular, gespeist mit `actionData`, sodass das Ergebnis ohne JavaScript und das auf der Seite gleich aussehen.

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

### Das Footer-Formular

`app/components/newsletter-form.tsx`:

```tsx
import { useFetcher } from "react-router";
import type { Route } from "../routes/+types/newsletter";
import type { action } from "../routes/newsletter";

interface Props {
  source: "footer" | "post" | "newsletter-page";
  initial?: Route.ComponentProps["actionData"];
}

export function NewsletterForm({ source, initial }: Props) {
  const fetcher = useFetcher<typeof action>({ key: `newsletter-${source}` });
  const result = fetcher.data ?? initial;
  const busy = fetcher.state !== "idle";

  if (result?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
  const error = result?.ok === false ? result : null;

  return (
    <fetcher.Form method="post" action="/newsletter">
      <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={busy}>{busy ? "Subscribing" : "Subscribe"}</button>
    </fetcher.Form>
  );
}
```

- `fetcher.data` enthält nach einem Absenden im Hintergrund die Antwort der Action; `initial` enthält sie nach einem Dokument-POST. Was existiert, gewinnt.
- `fetcher.state` ist `submitting` und dann `loading`, bevor es zu `idle` zurückkehrt, also bleibt der Button in beiden Fällen deaktiviert.
- Ein `key` pro Platzierung gibt dem Footer-Formular und dem Formular auf der Seite `/newsletter` getrennten State, wenn beide zu sehen sind.

### Einbinden, ohne die Daten der Seite neu zu laden

`app/root.tsx`:

```tsx
import type { ShouldRevalidateFunctionArgs } from "react-router";
import { Outlet } from "react-router";
import { NewsletterForm } from "~/components/newsletter-form";

export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
  if (formAction === "/newsletter") return false;
  return defaultShouldRevalidate;
}

export default function App() {
  return (
    <>
      <Outlet />
      <footer>
        <p>One email a month. Unsubscribe from any issue.</p>
        <NewsletterForm source="footer" />
      </footer>
    </>
  );
}
```

Nachdem ein Fetcher an eine Action gesendet hat, validiert React Router standardmäßig die Loader jeder passenden Route neu. Eine Newsletter-Anmeldung ändert nichts, was sie laden, also überspringt `shouldRevalidate` an der Root-Route diese Arbeit, wenn die Einsendung an `/newsletter` ging. Füge denselben Export jeder Route mit teurem Loader hinzu. Das Formular kommt in die Default-Komponente `App` statt in `Layout`, weil `Layout` auch die Error Boundary umschließt.

## Abonnenten an MailerLite weiterleiten

Webhooks kommen von außerhalb deiner App, also ist dieser eine Resource Route: eine `action` ohne Komponente, die einfache `Response`-Objekte zurückgibt, wie es die Doku von React Router für externe Aufrufer empfiehlt. Sie liest den Body zuerst als Text, weil die Signatur genau die Bytes abdeckt. Sie sendet jede Anmeldung als `unconfirmed`, was zu einer neuen Liste passt. Hat deine Liste schon bestätigte Leser, nutze stattdessen das Relay aus der [MailerLite-Anleitung](https://ikiform.com/de/guides/newsletter-signup/mailerlite.md): Es schlägt die Adresse zuerst nach, sodass ein bestätigter Leser, der sich erneut anmeldet, seinen Status behält.

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

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { Route } from "./+types/api.ikiform";

export async function action({ request }: Route.ActionArgs) {
  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 answer = await fetch("https://connect.mailerlite.com/api/subscribers", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.MAILERLITE_API_KEY}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      email,
      fields: typeof first_name === "string" && first_name ? { name: first_name } : {},
      groups: [process.env.MAILERLITE_GROUP_ID],
      status: "unconfirmed",
    }),
  });

  if (answer.status === 429) return new Response("MailerLite rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!answer.ok) return new Response(`MailerLite 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.

## Honeypot und Menschlichkeitsprüfung

Der `_gotcha`-Input wird weitergeleitet, und Ikiform speichert einen ausgefüllten als Spam, mit Erfolgsantwort und ohne Webhook, sodass MailerLite ihn nie sieht. Der Secret Key überspringt die Turnstile-Prüfung von Ikiform, und jeder Post kommt jetzt von der IP deines Servers. Kommen Bots durch, verifiziere in der Action vor dem `fetch` ein Turnstile-Token, wie die [Turnstile-Anleitung](https://ikiform.com/de/guides/spam-protection/cloudflare-turnstile.md) zeigt.

## Rendering und Prerendering

Du kannst Artikel mit der Option `prerender` vorrendern und trotzdem `ssr: true` behalten; der Server führt `/newsletter` und die Webhook-Route weiterhin on demand aus. Der SPA-Modus, `ssr: false`, funktioniert hier nicht: Laut Doku von React Router sind Actions ohne Server zur Laufzeit verboten, und der Build scheitert, wenn eine Route eine hat. Eine vollständig statische React-Router-Site sollte stattdessen mit `@ikiform/sdk` aus dem Browser senden.

## Deployen

| Host | Was zu tun ist |
| --- | --- |
| Vercel | Deployt ohne Konfiguration. Vercel empfiehlt, `vercelPreset()` aus `@vercel/react-router/vite` zu `presets` in `react-router.config.ts` hinzuzufügen. Setze die Variablen unter **Environment Variables**. |
| Netlify | Installiere `@netlify/vite-plugin-react-router` und füge es zu den Vite-Plugins hinzu; SSR läuft als Netlify Functions. Setze die Variablen in den Umgebungseinstellungen der Site. |
| Cloudflare | Starte mit der React-Router-Vorlage für Workers. Lies Secrets mit `import { env } from "cloudflare:workers"` statt `process.env`; React Router 8 legt sie nicht mehr auf `context.cloudflare.env`. Nutze für den Webhook 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 den Weg des frühen Klicks, indem du JavaScript in den Entwicklertools deaktivierst. Ikiform stellt keine Webhooks an `localhost` zu; mach den Dev-Server über einen Tunnel mit HTTPS-URL erreichbar, um `/api/ikiform` zu testen.

## Prüfen, ob es funktioniert

1. Starte `react-router dev`, öffne einen Artikel und abonniere über den Footer. Die Zeile „Schau in deinen Posteingang“ erscheint, und die URL ändert sich nicht.
2. Öffne den Netzwerk-Tab und abonniere erneut von einer frischen Seite. Es gibt einen Request an `/newsletter` und danach keine Loader-Requests.
3. Deaktiviere JavaScript und abonniere. Du landest auf `/newsletter` mit derselben Zeile, nicht auf JSON.
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 MailerLite. Der Abonnent ist unbestätigt in der Gruppe, und die Bestätigungs-E-Mail liegt in deinem Posteingang.

## Stolperfallen

| Problem | Was zu tun ist |
| --- | --- |
| Der Build scheitert mit einem Fehler zu Actions | `ssr` ist `false`. Actions brauchen einen Server zur Laufzeit; schalte SSR ein und nutze `prerender` für die statischen Seiten. |
| Jede Anmeldung lädt die Daten der Seite neu | Loader validieren nach jeder Action neu. Füge `shouldRevalidate` hinzu, das `false` zurückgibt, wenn `formAction` `/newsletter` ist. |
| Der Secret Key ist auf Cloudflare `undefined` | Workers füllen `process.env` standardmäßig nicht. Lies das Secret in der Action aus `env` in `cloudflare:workers`. |
| `402 plan_required` bei lokalen Tests | Der Post hatte keinen `localhost`-Origin. Prüf die `Origin`-Zeile in der Action und öffne die App 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

### Funktioniert useFetcher ohne JavaScript?

Ja. fetcher.Form rendert ein normales HTML-Formular, also sendet es vor der Hydration an seine action-URL und navigiert dorthin. Mach diese URL zu einer Route mit Komponente, sonst sieht der Leser die rohen Daten der Action.

### Sollte die Newsletter-Action eine Resource Route sein?

Nicht für ein Formular, das Leute absenden. Nutze eine Route mit Default-Komponente, damit der Post ohne JavaScript eine Seite zeigt. Resource Routes sind für Webhooks und andere Aufrufer, die keine Browser sind.

### Warum Abonnenten per Webhook statt in der Action zu MailerLite hinzufügen?

Aus zwei Gründen. Die Action bekommt für Spam dieselbe Erfolgsantwort wie für eine echte Anmeldung, würde also auch Bots hinzufügen. Und ein Fehler von MailerLite in der Action würde einen Fehlschlag für eine Anmeldung zeigen, die Ikiform schon gespeichert hat, während eine fehlgeschlagene Webhook-Zustellung wiederholt wird.

### 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.
- [MailerLite](https://ikiform.com/de/guides/newsletter-signup/mailerlite.md): Verbinde dein eigenes Anmeldeformular mit MailerLite: Ein Relay fügt neue Leser für das Double Opt-in unbestätigt hinzu und bittet einen Abonnenten nie zweimal um Bestätigung.
- [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 TanStack Start](https://ikiform.com/de/guides/newsletter-signup/tanstack-start.md): 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.
- [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.
