Warteliste für ein neues SaaS7 Min. Lesezeit
Eine Warteliste in React Router bauen
Eine React-Router-Warteliste im Footer jeder Seite: Ein Fetcher sendet an eine Route-Action, markiert jede Anmeldung mit ihrer Seite und funktioniert ohne JavaScript.
Gib der Warteliste eine eigene Route mit einer action, die die E-Mail validiert und sie mit einem Secret Key an dein Formular-Backend weiterleitet. Rendere das Formular in deinem Layout mit useFetcher, damit eine Anmeldung von jeder Seite auf dieser Seite bleibt und mit ihrem Pfad markiert wird; ohne JavaScript sendet dasselbe Formular an die Wartelisten-Route, und deren Seite zeigt das Ergebnis.
Die Hauptanleitung hat einen React-Router-Tab mit dem Formular und seiner action auf der Startseiten-Route. Das passt zu einer Site mit einer Seite. Die meisten SaaS-Sites haben vor dem Launch ein paar Seiten, etwa Preise, einen Blogpost und die Startseite, und du willst die Anmeldung auf allen und wissen, welche funktioniert hat. Diese Seite setzt das Formular ins Layout und sendet jede Kopie an eine Action.
Was in React Router anders ist
Im Framework-Modus gehört eine Action zu einer Route, und ein <Form> sendet an die Route, in der es steht. Ein Formular im Layout steht in jeder Route, die Wahl der Komponente entscheidet also, was beim Absenden passiert:
| Item | <Form method="post"> | <fetcher.Form action="/waitlist"> |
|---|---|---|
| Wohin es sendet | Die aktuelle Route, außer du setzt action | Die Action der Route /waitlist |
| Nach dem Absenden | Eine Navigation. URL und Verlauf ändern sich | Keine Navigation. Der Besucher bleibt auf der Seite |
| Das Ergebnis | actionData der Route, die gelaufen ist | fetcher.data, geteilt von jedem Fetcher mit demselben key |
| Ohne JavaScript | Ein normaler Post | Ein normaler Post an /waitlist, das seine eigene Seite rendert |
Ein Fetcher passt zu einer Footer-Anmeldung: Niemand will die Preisseite verlassen, um sich auf eine Liste zu setzen. Er degradiert auch sauber. Vor der Hydration ist fetcher.Form ein gewöhnliches <form>, das an /waitlist sendet, diese Route braucht also eine eigene Seite, um das Ergebnis zu zeigen.
- Das Layout rendert das Wartelisten-Formular auf jeder Seite, mit einem versteckten ref aus dem Pfad der Seite.
- Mit JavaScript sendet fetcher.Form an /waitlist, ohne die Seite zu verlassen. Der key des Fetchers ist geteilt, ein Hinweis im Header sieht also dasselbe Ergebnis.
- Ohne JavaScript sendet dasselbe Formular als normale Navigation an /waitlist.
- So oder so prüft die Action von /waitlist die E-Mail und leitet die Anmeldung mit dem Secret Key an Ikiform weiter.
- Ein Fetcher liest das Ergebnis aus fetcher.data. Ein einfacher Post landet auf der Seite /waitlist, die es aus actionData zeigt.
Das Projekt einrichten
Die Server-Anleitung behandelt das Anlegen des Formulars und eines Secret Keys. Die Dateien auf dieser Seite:
app/
root.tsx
routes.ts
routes/home.tsx
routes/pricing.tsx
routes/waitlist.tsx
components/waitlist-form.tsx
react-router.config.ts
.envLeg den Schlüssel in .env ab und lies ihn in der Action aus process.env. Route-Module werden für den Browser aufgeteilt, aber action läuft nur auf dem Server, der Schlüssel landet also nie beim Client. Importier ihn nie in eine Komponente.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Die Action sendet email und ref. Wenn das Formular im Tab Fields eine Felddefinition hat, muss sie beide auflisten:
{
"version": 1,
"title": "Waitlist",
"fields": [
{ "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
{ "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
]
}Bauen
Die Route registrieren
import { index, type RouteConfig, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("pricing", "routes/pricing.tsx"),
route("waitlist", "routes/waitlist.tsx"),
] satisfies RouteConfig;Die Wartelisten-Route
import { data } from "react-router";
import { WaitlistForm } from "../components/waitlist-form";
import type { Route } from "./+types/waitlist";
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SOURCE = /^[a-z0-9_-]{1,40}$/;
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const email = String(form.get("email") ?? "").trim();
if (!EMAIL.test(email)) {
return data({ ok: false, message: "Enter the email we should send your invite to.", email }, { status: 400 });
}
const ref = String(form.get("ref") ?? "");
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,
ref: SOURCE.test(ref) ? ref : "direct",
_gotcha: String(form.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return { ok: true, message: "You're on the list. We'll email you on launch day.", email };
return data(
{
ok: false,
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
email,
},
{ status: response?.status === 429 ? 429 : 400 },
);
}
export default function Waitlist({ actionData }: Route.ComponentProps) {
return (
<main>
<h1>Join the Acme waitlist</h1>
<WaitlistForm initial={actionData} />
</main>
);
}data()setzt den Status eines Fehlers, ohne ihn in eine geworfene Response zu verwandeln, die Komponente rendert die Meldung also trotzdem.- Jede Antwort bringt die E-Mail zurück, damit das Eingabefeld sie nach einem Fehler wieder anzeigen kann.
- Der
Origin-Header des Browsers wird weitergereicht, weil Ikiform kostenloselocalhost-Testeinsendungen daran zählt und das eigenefetchder Action keinen sendet. refmuss aus Kleinbuchstaben, Ziffern,-oder_bestehen, bis zu 40 Zeichen, dieselbemax_lengthwie in der Definition. Alles andere wird alsdirectgespeichert.- Der Default-Export ist der Fallback ohne JavaScript. Er rendert dasselbe Formular mit dem Ergebnis der Action.
Das geteilte Formular
import { useFetcher, useLocation } from "react-router";
interface Outcome {
ok: boolean;
message: string;
email: string;
}
export function sourceFor(pathname: string) {
const slug = pathname.replace(/^\/+|\/+$/g, "").replace(/\//g, "-").toLowerCase();
return (slug || "home").slice(0, 40);
}
export function WaitlistForm({ initial }: { initial?: Outcome }) {
const fetcher = useFetcher<Outcome>({ key: "waitlist" });
const { pathname } = useLocation();
const outcome = fetcher.data ?? initial;
const pending = fetcher.state !== "idle";
if (outcome?.ok) return <p role="status">{outcome.message}</p>;
return (
<fetcher.Form method="post" action="/waitlist">
<label>
Email
<input name="email" type="email" autoComplete="email" required defaultValue={outcome?.email} />
</label>
<input type="hidden" name="ref" value={sourceFor(pathname)} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<p role="alert">{outcome?.message}</p>
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</fetcher.Form>
);
}
export function JoinedNote() {
const fetcher = useFetcher<Outcome>({ key: "waitlist" });
return fetcher.data?.ok ? <span>You're on the list</span> : <a href="#waitlist">Join the waitlist</a>;
}key: "waitlist"macht alle Fetcher mit diesem key zu einem geteilten Fetcher.JoinedNoteim Header liest dieselbenfetcher.data, ändert sich also in dem Moment, in dem das Footer-Formular erfolgreich ist.sourceFormacht aus/blog/launcheinblog-launchund aus/einhome. Der Pfad ist auf dem Server bekannt, das funktioniert also auch auf vorgerenderten Seiten.fetcher.stateistsubmitting, während der Post läuft, dannloading, während React Router revalidiert, der Button bleibt also deaktiviert, bis beides fertig ist.- Das Label umschließt das Eingabefeld, die Kopien im Hero und im Footer auf einer Seite teilen sich also keine
id.
Rendere <WaitlistForm /> im Layout von app/root.tsx, in einem Footer mit id="waitlist", und <JoinedNote /> in deinem Header. Der Honeypot reist mit und erreicht Ikiform unverändert. Posts mit dem Secret Key überspringen Ikiforms Menschlichkeitsprüfung, Spam check zu aktivieren bringt für dieses Formular also nichts.
Das Neuladen jedes Loaders überspringen
Nach einer Action lädt React Router die Daten jeder Route auf der Seite neu. Eine Anmeldung ändert nichts davon. Füge das Routen hinzu, deren Loader langsam sind oder Geld kosten:
import type { ShouldRevalidateFunctionArgs } from "react-router";
export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
if (formAction === "/waitlist") return false;
return defaultShouldRevalidate;
}Rendering und Deployment
Marketingseiten ändern sich nicht pro Besucher, rendere sie also vor und behalte den Server für die Action:
import type { Config } from "@react-router/dev/config";
export default {
ssr: true,
prerender: ["/", "/pricing"],
} satisfies Config;Behalte ssr: true. Mit ssr: false gibt es keinen Laufzeitserver, und React Router lässt den Build für jede Route scheitern, die eine action exportiert. Mit ssr: true werden / und /pricing beim Build als HTML geschrieben, und /waitlist läuft weiterhin auf dem Server.
| Host | Was hinzuzufügen ist | Den Schlüssel lesen |
|---|---|---|
| Vercel | @vercel/react-router, mit presets: [vercelPreset()] in react-router.config.ts | process.env, gesetzt unter Environment Variables |
| Netlify | @netlify/vite-plugin-react-router, als netlifyReactRouter() in den Vite-Plugins | process.env, gesetzt unter Environment variables |
| Cloudflare Workers | @cloudflare/vite-plugin, als cloudflare() vor reactRouter() | env.IKIFORM_SECRET_KEY aus cloudflare:workers, gesetzt mit wrangler secret put |
Auf Cloudflare nutzt du import { env } from "cloudflare:workers" und liest den Schlüssel daraus statt aus process.env. In React Router 8 ist der context der Action ein RouterContextProvider, das v7-Muster context.cloudflare.env gilt also nicht mehr. Der Rest der Action bleibt gleich.
Prüfen, ob es funktioniert
- Führe
react-router devaus, öffnehttp://localhost:5173/pricingund melde dich im Footer an. Die Dankeszeile ersetzt das Formular, und der Hinweis im Header ändert sich mit. - Öffne im Dashboard Submissions. Die Anmeldung hat
refaufpricing. - Schalte JavaScript aus und melde dich auf der Startseite an. Du landest mit der Dankeszeile auf
/waitlist, und die Anmeldung hatrefaufhome. - Sende
a@baus dem Footer ab. Der Fehler erscheint unter dem Eingabefeld, und die Adresse steht noch darin. - Sende einen Post mit ausgefülltem Honeypot,
_gotcha=bot. Er bekommt trotzdem eine Erfolgsantwort, aber die Einsendung wird als Spam gespeichert und löst weder E-Mail noch Webhook aus. - Posts aus
react-router devtragen einenlocalhost-Origin, zählen also als Testeinsendungen. Ikiform hat keinen kostenlosen Tarif. Testen ist kostenlos: Ein Workspace ohne Tarif nimmt 50 Testeinsendungen pro Monat vonlocalhostan. Für den Livebetrieb wähle Pro ab $6/Monat für 500 Einsendungen oder zahle einmalig: 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt. Siehe Preise.
Stolperfallen
| Problem | Was zu tun ist |
|---|---|
Eine Footer-Anmeldung navigiert auch mit JavaScript zu /waitlist | Das Formular ist ein <Form> oder ein einfaches <form>, nicht fetcher.Form. Nutze den Fetcher und gib aus der Action kein redirect() zurück; eine Weiterleitung aus der Action eines Fetchers navigiert. |
Der Build scheitert mit einem Fehler zu action-Exporten | ssr ist in react-router.config.ts auf false. Setze es auf true, sonst hat die Warteliste keinen Server, auf dem sie laufen kann. |
Das ref jeder Anmeldung ist waitlist | Die Posts kommen von der Seite /waitlist selbst. Das ist die Fallback-Seite; Links sollten auf deine echten Seiten zeigen. |
422 mit unknown_field | Die Felddefinition listet ref nicht auf. Füge es wie in der Definition oben hinzu. |
402 plan_required | Der Workspace hat keinen Tarif, und der Post kam von einer Live-Site. Er wird zurückgehalten, nicht verloren. Wähle einen Tarif oder kauf ein Paket unter Settings › Plan and usage, und zurückgehaltene Einsendungen kommen an. |
Fragen
Nutze useFetcher und rendere fetcher.Form mit der action der Route, die es verarbeitet. Der Besucher bleibt auf der Seite, und das Ergebnis kommt in fetcher.data an. Vor der Hydration sendet es als normales Formular.
Ja, wenn ssr true ist. Vorgerenderte Seiten werden als HTML ausgeliefert, und ihre Formulare senden an eine Action, die auf dem Server läuft. Mit ssr auf false sind Actions nicht erlaubt.
Setz den Pfad der Seite in ein verstecktes ref-Eingabefeld, wie es sourceFor oben tut. Der Pfad ist auf Server und Client bekannt, das funktioniert also auf vorgerenderten Seiten und vor der Hydration.
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.