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.
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:
- Vor der Hydration ist fetcher.Form ein einfaches Formular, also sendet der Browser an /newsletter und navigiert dorthin.
- Ist /newsletter eine Resource Route, zeigt der Browser das JSON der Action als Seite.
- Hat /newsletter eine Default-Komponente, führt React Router die Action aus und rendert dann die Seite mit dem Ergebnis als actionData.
- Sobald JavaScript geladen ist, verhalten sich beide Versionen gleich: Der Fetcher sendet im Hintergrund, und der Leser bleibt, wo er ist.
Projekt-Setup
Das nutzt den Framework-Modus mit aktiviertem Server-Rendering, ssr: true in react-router.config.ts, was der Standard ist.
app/
root.tsx
routes.ts
routes/
home.tsx
newsletter.tsx
api.ikiform.ts
components/newsletter-form.tsx
react-router.config.ts
.envimport { 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;IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789Erstelle 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
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 einem4xx-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 kostenloselocalhost-Tests an diesem Header, und dasfetcheines Servers sendet keinen. - Die Default-Komponente rendert dasselbe Formular, gespeist mit
actionData, sodass das Ergebnis ohne JavaScript und das auf der Seite gleich aussehen.
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Das Footer-Formular
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.dataenthält nach einem Absenden im Hintergrund die Antwort der Action;initialenthält sie nach einem Dokument-POST. Was existiert, gewinnt.fetcher.stateistsubmittingund dannloading, bevor es zuidlezurückkehrt, also bleibt der Button in beiden Fällen deaktiviert.- Ein
keypro Platzierung gibt dem Footer-Formular und dem Formular auf der Seite/newslettergetrennten State, wenn beide zu sehen sind.
Einbinden, ohne die Daten der Seite neu zu laden
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.
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
| 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
- 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. - Öffne den Netzwerk-Tab und abonniere erneut von einer frischen Seite. Es gibt einen Request an
/newsletterund danach keine Loader-Requests. - Deaktiviere JavaScript und abonniere. Du landest auf
/newslettermit derselben Zeile, nicht auf JSON. - Ö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 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
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.
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.
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.
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.