Warteliste für ein neues SaaS

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.

Preet SutharGründer, Ikiform

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 sendetDie aktuelle Route, außer du setzt actionDie Action der Route /waitlist
Nach dem AbsendenEine Navigation. URL und Verlauf ändern sichKeine Navigation. Der Besucher bleibt auf der Seite
Das ErgebnisactionData der Route, die gelaufen istfetcher.data, geteilt von jedem Fetcher mit demselben key
Ohne JavaScriptEin normaler PostEin 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.

Footer-Anmeldungen von jeder React-Router-Seite laufen durch eine Route-Action/pricingref = pricingfetcher.Formkey: waitlistAction/waitlistleitet anIkiform weiterfetcher.datagleiche Seite,Headeraktualisiert/blog/launchref =blog-launcheinfachesFormularJavaScriptnicht geladenAction/waitlistderselbe CodeSeite/waitlistactionDataPOSTPOSTSeiteFormularActionErgebnisFooter-Anmeldungen von jeder React-Router-Seite laufen durch eine Route-Action/pricingref = pricingfetcher.Formkey: waitlistAction /waitlistleitet an Ikiform weiterfetcher.datagleiche Seite, Headeraktualisiert/blog/launchref = blog-launcheinfaches FormularJavaScript nicht geladenAction /waitlistderselbe CodeSeite /waitlistactionDataPOSTPOST
  1. Das Layout rendert das Wartelisten-Formular auf jeder Seite, mit einem versteckten ref aus dem Pfad der Seite.
  2. 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.
  3. Ohne JavaScript sendet dasselbe Formular als normale Navigation an /waitlist.
  4. So oder so prüft die Action von /waitlist die E-Mail und leitet die Anmeldung mit dem Secret Key an Ikiform weiter.
  5. Ein Fetcher liest das Ergebnis aus fetcher.data. Ein einfacher Post landet auf der Seite /waitlist, die es aus actionData zeigt.
Eine Action, beliebig viele Formulare. Der Pfad sagt dir, welche Seite die Anmeldung gebracht hat.

Das Projekt einrichten

Die Server-Anleitung behandelt das Anlegen des Formulars und eines Secret Keys. Die Dateien auf dieser Seite:

Projekt
app/
  root.tsx
  routes.ts
  routes/home.tsx
  routes/pricing.tsx
  routes/waitlist.tsx
  components/waitlist-form.tsx
react-router.config.ts
.env

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

.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
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.

Die Action sendet email und ref. Wenn das Formular im Tab Fields eine Felddefinition hat, muss sie beide auflisten:

Felder
{
  "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

app/routes.ts
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

app/routes/waitlist.tsx
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 kostenlose localhost-Testeinsendungen daran zählt und das eigene fetch der Action keinen sendet.
  • ref muss aus Kleinbuchstaben, Ziffern, - oder _ bestehen, bis zu 40 Zeichen, dieselbe max_length wie in der Definition. Alles andere wird als direct gespeichert.
  • Der Default-Export ist der Fallback ohne JavaScript. Er rendert dasselbe Formular mit dem Ergebnis der Action.

Das geteilte Formular

app/components/waitlist-form.tsx
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. JoinedNote im Header liest dieselben fetcher.data, ändert sich also in dem Moment, in dem das Footer-Formular erfolgreich ist.
  • sourceFor macht aus /blog/launch ein blog-launch und aus / ein home. Der Pfad ist auf dem Server bekannt, das funktioniert also auch auf vorgerenderten Seiten.
  • fetcher.state ist submitting, während der Post läuft, dann loading, 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:

app/routes/pricing.tsx
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:

react-router.config.ts
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.

HostWas hinzuzufügen istDen Schlüssel lesen
Vercel@vercel/react-router, mit presets: [vercelPreset()] in react-router.config.tsprocess.env, gesetzt unter Environment Variables
Netlify@netlify/vite-plugin-react-router, als netlifyReactRouter() in den Vite-Pluginsprocess.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

  1. Führe react-router dev aus, öffne http://localhost:5173/pricing und melde dich im Footer an. Die Dankeszeile ersetzt das Formular, und der Hinweis im Header ändert sich mit.
  2. Öffne im Dashboard Submissions. Die Anmeldung hat ref auf pricing.
  3. Schalte JavaScript aus und melde dich auf der Startseite an. Du landest mit der Dankeszeile auf /waitlist, und die Anmeldung hat ref auf home.
  4. Sende a@b aus dem Footer ab. Der Fehler erscheint unter dem Eingabefeld, und die Adresse steht noch darin.
  5. 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.
  6. Posts aus react-router dev tragen einen localhost-Origin, zählen also als Testeinsendungen. Ikiform hat keinen kostenlosen Tarif. Testen ist kostenlos: Ein Workspace ohne Tarif nimmt 50 Testeinsendungen pro Monat von localhost an. 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

ProblemWas zu tun ist
Eine Footer-Anmeldung navigiert auch mit JavaScript zu /waitlistDas 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-Exportenssr 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 waitlistDie Posts kommen von der Seite /waitlist selbst. Das ist die Fallback-Seite; Links sollten auf deine echten Seiten zeigen.
422 mit unknown_fieldDie Felddefinition listet ref nicht auf. Füge es wie in der Definition oben hinzu.
402 plan_requiredDer 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

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.