Newsletter-Anmeldeformular

Newsletter-Anmeldeformular7 Min. Lesezeit

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.

Preet SutharGründer, Ikiform

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

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:

Was ein Leser ohne JavaScript nach dem Absenden des Footer-Formulars siehtAction in einer Resource RouteAction in einer UI-RouteFooter-Formularfetcher.Form,nicht hydriert/newsletterAction ohneKomponenterohes JSON{"ok":true}Footer-Formularfetcher.Form,nicht hydriert/newsletterAction +Default-KomponenteErgebnisseiteactionDatagerendertDokument-POSTAntwortDokument-POSTrendernWas ein Leser ohne JavaScript nach dem Absenden des Footer-Formulars siehtAction in einer Resource RouteAction in einer UI-RouteFooter-Formularfetcher.Form, nicht hydriert/newsletterAction ohne Komponenterohes JSON{"ok":true}Footer-Formularfetcher.Form, nicht hydriert/newsletterAction + Default-KomponenteErgebnisseiteactionData gerendertDokument-POSTAntwortDokument-POSTrendern
  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
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
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
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 im Tab Fields des Formulars ein.

Bauen

Die Route /newsletter

app/routes/newsletter.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 Relaxed oder Custom, und halte IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.

Das Footer-Formular

app/components/newsletter-form.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
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: 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
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 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

HostWas zu tun ist
VercelDeployt 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.
NetlifyInstalliere @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.
CloudflareStarte 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

ProblemWas zu tun ist
Der Build scheitert mit einem Fehler zu Actionsssr 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 neuLoader validieren nach jeder Action neu. Füge shouldRevalidate hinzu, das false zurückgibt, wenn formAction /newsletter ist.
Der Secret Key ist auf Cloudflare undefinedWorkers füllen process.env standardmäßig nicht. Lies das Secret in der Action aus env in cloudflare:workers.
402 plan_required bei lokalen TestsDer 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 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.