Warteliste für ein neues SaaS

Warteliste für ein neues SaaS7 Min. Lesezeit

Eine Warteliste in TanStack Start bauen

Eine TanStack-Start-Warteliste: Eine Server-Route beantwortet sowohl einen einfachen Formular-Post als auch fetch, und validateSearch trägt Quelle und Ergebnis in der URL.

Preet SutharGründer, Ikiform

Füge unter /api/waitlist eine Server-Route hinzu, die die E-Mail validiert, die Anmeldung mit einem Secret Key an dein Formular-Backend weiterleitet und fetch mit JSON oder einen einfachen Formular-Post mit einer 303-Weiterleitung beantwortet. Die Landing-Route liest ?ref=, ?joined= und ?problem= über validateSearch, Quellen-Tag und Ergebnis bleiben also mit und ohne JavaScript erhalten.

Die Hauptanleitung hat einen TanStack-Start-Tab auf Basis von createServerFn und useServerFn. Er ist kurz, aber das Formular funktioniert erst, wenn React hydriert ist, und die Quelle jeder Anmeldung ist ein fester String. Diese Seite baut die Version, die du am Launch-Tag teilen würdest: Sie sendet vor der Hydration, markiert jede Anmeldung anhand des Links, über den sie kam, und hält das Ergebnis in der URL.

Was in TanStack Start anders ist

Start gibt dir zwei Wege, Code auf dem Server auszuführen, und sie verhalten sich bei einem Formular unterschiedlich:

ItemServerfunktionServer-Route
Definiert mitcreateServerFn().handler()createFileRoute() mit server.handlers
Aufgerufen vonDeinen Komponenten, über einen RPC-AufrufJedem HTTP-Client, einschließlich eines einfachen <form>
AntwortFür den Aufrufer serialisiertEine Response, die du baust
Ohne JavaScriptÜber ihre .url möglich, aber du kontrollierst die Antwort nichtDu entscheidest: JSON oder eine Weiterleitung zurück zur Seite

Ein Wartelisten-Post kommt von zwei Arten von Clients: einem Browser, der dein Skript ausführt, und einem Browser, der es noch nicht geladen hat. Eine Server-Route kann beiden so antworten, wie sie es erwarten, aus einem Handler. Der Accept-Header unterscheidet sie: Das Skript verlangt application/json, ein einfacher Formular-Post nicht.

Wie die TanStack-Start-Server-Route jede Art von Post beantwortetLanding-RoutevalidateSearchliest?ref=POST/api/waitlistprüftE-Mail,leitetweiterIkiformSecretKey,speichertsieJSONdas Skriptzeigt dasErgebnis303 zu /?joined=yesoder?problem=emailLanding-RouterendertdasErgebnisFormDataJSONAccept: jsoneinfacher PostWie die TanStack-Start-Server-Route jede Art von Post beantwortetLanding-RoutevalidateSearch liest?ref=POST/api/waitlistprüft E-Mail, leitetweiterIkiformSecret Key, speichertsieJSONdas Skript zeigt dasErgebnis303 zu /?joined=yesoder?problem=emailLanding-Routerendert dasErgebnisFormDataJSONAccept: jsoneinfacher Post
  1. Die Landing-Route liest ?ref= über validateSearch und rendert es in ein verstecktes Eingabefeld.
  2. Das Formular sendet seine FormData an die Server-Route, mit fetch, wenn das Skript geladen ist, oder als einfacher Post, wenn nicht.
  3. Die Route prüft die E-Mail und den Quellen-Tag und leitet die Anmeldung dann mit dem Secret Key an Ikiform weiter.
  4. Ein fetch-Post hat JSON verlangt, bekommt also { ok } oder eine Meldung, und die Komponente blendet die Dankeszeile oder den Fehler ein.
  5. Ein einfacher Post bekommt einen 303 zurück zur Landingpage, mit ?joined=yes oder einem ?problem=-Code und demselben ref. validateSearch liest sie, und die Seite rendert das Ergebnis.
Ein Handler, zwei Antworten. Die URL trägt das Ergebnis für den Besucher ohne JavaScript.

Das Projekt einrichten

Erstelle zuerst das Formular und einen Secret Key; die Server-Anleitung behandelt beides. Das sind die Dateien, die du anfasst:

Projekt
src/
  routes/
    __root.tsx
    index.tsx
    api/waitlist.ts
  waitlist-messages.ts
vite.config.ts
.env.local

Speichere den Schlüssel in .env.local, das Start in der Entwicklung lädt und das nicht ins Git kommt. Servercode liest ihn aus process.env. Gib ihm nie ein VITE_-Präfix; das bündelt ihn in den Browser-Code.

.env.local
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 Route sendet email und ref. Wenn du im Tab Fields des Formulars eine Felddefinition hinzufügst, liste beide auf:

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

Fehlercodes, kein Fehlertext

src/waitlist-messages.ts
export type Problem = "email" | "busy" | "failed";

export const MESSAGES: Record<Problem, string> = {
  email: "Enter the email we should send your invite to.",
  busy: "Lots of people are joining. Try again in a minute.",
  failed: "Couldn't join the list. Try again.",
};

export function isProblem(value: unknown): value is Problem {
  return typeof value === "string" && value in MESSAGES;
}

Die Weiterleitung setzt einen kurzen Code in die URL, nie die Meldung selbst. Ein Link mit ?problem= kann dann nur einen deiner drei Sätze zeigen, keinen beliebigen Text, den ein Fremder in eine URL tippt.

Die Server-Route

src/routes/api/waitlist.ts
import { createFileRoute } from "@tanstack/react-router";
import { MESSAGES, type Problem } from "../../waitlist-messages";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SOURCE = /^[a-z0-9_-]{1,40}$/;

async function forward(request: Request, form: FormData): Promise<Problem | null> {
  const email = String(form.get("email") ?? "").trim();
  if (!EMAIL.test(email)) return "email";
  const ref = String(form.get("ref") ?? "");
  const origin = request.headers.get("origin");

  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 ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      email,
      ref: SOURCE.test(ref) ? ref : "direct",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  if (!response) return "failed";
  if (response.status === 429) return "busy";
  const result = await response.json().catch(() => null);
  if (result?.ok) return null;
  return result?.error?.fields?.email ? "email" : "failed";
}

export const Route = createFileRoute("/api/waitlist")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const form = await request.formData();
        const problem = await forward(request, form);

        if (request.headers.get("accept")?.includes("application/json")) {
          return problem
            ? Response.json({ ok: false, message: MESSAGES[problem] }, { status: problem === "busy" ? 429 : 400 })
            : Response.json({ ok: true });
        }

        const back = new URL("/", request.url);
        back.searchParams.set("ref", String(form.get("ref") ?? ""));
        if (problem) back.searchParams.set("problem", problem);
        else back.searchParams.set("joined", "yes");
        return Response.redirect(back, 303);
      },
    },
  },
});
  • request.formData() liest beide Kodierungen: das multipart/form-data, das fetch mit einem FormData-Body sendet, und den urlencoded Body eines einfachen Posts.
  • process.env wird im Handler gelesen, zur Request-Zeit. Auf Cloudflare Workers enthält es deine Secrets nur mit aktivem nodejs_compat und einem Compatibility Date ab 2025-04-01, wie in der wrangler.jsonc unten.
  • Der Origin-Header des Browsers wird weitergereicht, weil Ikiform kostenlose localhost-Testeinsendungen daran zählt und das eigene fetch der Route keinen sendet.
  • ref muss aus Kleinbuchstaben, Ziffern, - oder _ bestehen, bis zu 40 Zeichen, passend zur max_length in der Definition. Alles andere wird als direct gespeichert.
  • Response.redirect(back, 303) sagt dem Browser, die Landingpage mit einem GET zu laden, damit ein Neuladen die Anmeldung nicht doppelt sendet.

Die Landing-Route

src/routes/index.tsx
import { createFileRoute } from "@tanstack/react-router";
import { type FormEvent, useState } from "react";
import { isProblem, MESSAGES, type Problem } from "../waitlist-messages";

interface Search {
  ref?: string;
  joined?: boolean;
  problem?: Problem;
}

export const Route = createFileRoute("/")({
  validateSearch: (search: Record<string, unknown>): Search => ({
    ref: typeof search.ref === "string" ? search.ref.toLowerCase().slice(0, 40) : undefined,
    joined: search.joined === "yes" ? true : undefined,
    problem: isProblem(search.problem) ? search.problem : undefined,
  }),
  component: Home,
});

function Home() {
  const search = Route.useSearch();
  const [joined, setJoined] = useState(Boolean(search.joined));
  const [error, setError] = useState(search.problem ? MESSAGES[search.problem] : "");
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    setError("");
    try {
      const response = await fetch("/api/waitlist", {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const outcome = await response.json();
      if (outcome.ok) setJoined(true);
      else setError(outcome.message);
    } catch {
      setError("Couldn't reach the server. Try again.");
    } finally {
      setPending(false);
    }
  }

  if (joined) return <p role="status">You're on the list. We'll email you on launch day.</p>;

  return (
    <form method="post" action="/api/waitlist" onSubmit={onSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required placeholder="you@company.com" />
      <input type="hidden" name="ref" value={search.ref ?? "direct"} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <p role="alert">{error}</p>
      <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
    </form>
  );
}
  • validateSearch macht aus der rohen Query ein typisiertes Objekt. Unbekannte Werte werden zu undefined, ein kaputter Link rendert also das einfache Formular.
  • Das versteckte Eingabefeld ref wird auf dem Server aus den Search-Params gefüllt. Ein einfacher Post trägt es ganz ohne Skript mit.
  • Das Formular hat eine echte method und action. Wenn das Bundle nicht geladen ist, sendet der Browser an die Server-Route und folgt der Weiterleitung.
  • Nach der Hydration übernimmt onSubmit und sendet Accept: application/json, damit der Besucher auf der Seite bleibt.

Der Honeypot reist in denselben FormData mit und geht unverändert an Ikiform, Bots, die ihn ausfüllen, werden also als Spam gespeichert. Posts mit dem Secret Key überspringen Ikiforms Menschlichkeitsprüfung, Spam check zu aktivieren bringt hier also nichts; diese Route ist das Tor.

Rendering und Deployment

Lass die Landing-Route serverseitig gerendert, was der Standard von Start ist. Sie liest den Query-String, um ref und das Ergebnis zu rendern, und eine vorgerenderte Seite wird einmal ohne Query gebaut. Wenn du prerender im Plugin tanstackStart() für andere Seiten aktivierst, lass / aus.

HostWas hinzuzufügen istDas Secret
VercelDas Vite-Plugin nitro() aus dem Paket nitroEnvironment Variables in den Projekteinstellungen
Netlify@netlify/vite-plugin-tanstack-start, als netlify() in den Vite-PluginsSite configuration › Environment variables (Umgebungsvariablen)
Cloudflare Workers@cloudflare/vite-plugin mit viteEnvironment: { name: "ssr" } und einer wrangler.jsoncwrangler secret put IKIFORM_SECRET_KEY
wrangler.jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "acme-waitlist",
  "compatibility_date": "2025-09-02",
  "compatibility_flags": ["nodejs_compat"],
  "main": "@tanstack/react-start/server-entry"
}

nodejs_compat macht process.env auf Workers verfügbar. Ohne es fehlt der Schlüssel, und jeder Post scheitert mit failed.

Prüfen, ob es funktioniert

  1. Führe vite dev aus, öffne http://localhost:3000/?ref=test und melde dich an. Die Dankeszeile ersetzt das Formular ohne Neuladen.
  2. Öffne im Dashboard Submissions. Die Anmeldung ist da, mit ref auf test.
  3. Schalte JavaScript aus, lade dieselbe URL neu und melde dich erneut an. Die Adressleiste zeigt ?joined=yes, und auch die zweite Anmeldung hat ref auf test.
  4. Sende mit ausgeschaltetem JavaScript a@b ab und prüfe, dass die Seite mit der E-Mail-Meldung und ?problem=email zurückkommt.
  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 vite 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
Ein einfacher Post zeigt rohes JSONDer Browser hat application/json in seinem Accept-Header gesendet, was normale Formular-Posts nie tun. Prüfe, dass keine Erweiterung und kein Proxy ihn umschreibt, oder unterscheide stattdessen an einem eigenen Header, den fetch sendet.
Jeder Post antwortet in der Produktion mit failedIKIFORM_SECRET_KEY ist auf dem Host nicht gesetzt. Prüfe auf Workers außerdem, dass nodejs_compat aktiv ist und das Compatibility Date 2025-04-01 oder später ist, sonst bleibt process.env leer.
Zwei Dateien beanspruchen /api/waitlistStart erlaubt eine Handler-Datei pro Pfad. api/waitlist.ts und api/waitlist/index.ts lösen beide darauf auf; behalte eine.
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.