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.
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:
| Item | Serverfunktion | Server-Route |
|---|---|---|
| Definiert mit | createServerFn().handler() | createFileRoute() mit server.handlers |
| Aufgerufen von | Deinen Komponenten, über einen RPC-Aufruf | Jedem HTTP-Client, einschließlich eines einfachen <form> |
| Antwort | Für den Aufrufer serialisiert | Eine Response, die du baust |
| Ohne JavaScript | Über ihre .url möglich, aber du kontrollierst die Antwort nicht | Du 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.
- Die Landing-Route liest ?ref= über validateSearch und rendert es in ein verstecktes Eingabefeld.
- Das Formular sendet seine FormData an die Server-Route, mit fetch, wenn das Skript geladen ist, oder als einfacher Post, wenn nicht.
- Die Route prüft die E-Mail und den Quellen-Tag und leitet die Anmeldung dann mit dem Secret Key an Ikiform weiter.
- Ein fetch-Post hat JSON verlangt, bekommt also { ok } oder eine Meldung, und die Komponente blendet die Dankeszeile oder den Fehler ein.
- 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.
Das Projekt einrichten
Erstelle zuerst das Formular und einen Secret Key; die Server-Anleitung behandelt beides. Das sind die Dateien, die du anfasst:
src/
routes/
__root.tsx
index.tsx
api/waitlist.ts
waitlist-messages.ts
vite.config.ts
.env.localSpeichere 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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:
{
"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
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
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: dasmultipart/form-data, dasfetchmit einemFormData-Body sendet, und den urlencoded Body eines einfachen Posts.process.envwird im Handler gelesen, zur Request-Zeit. Auf Cloudflare Workers enthält es deine Secrets nur mit aktivemnodejs_compatund einem Compatibility Date ab 2025-04-01, wie in derwrangler.jsoncunten.- Der
Origin-Header des Browsers wird weitergereicht, weil Ikiform kostenloselocalhost-Testeinsendungen daran zählt und das eigenefetchder Route keinen sendet. refmuss aus Kleinbuchstaben, Ziffern,-oder_bestehen, bis zu 40 Zeichen, passend zurmax_lengthin der Definition. Alles andere wird alsdirectgespeichert.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
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>
);
}validateSearchmacht aus der rohen Query ein typisiertes Objekt. Unbekannte Werte werden zuundefined, ein kaputter Link rendert also das einfache Formular.- Das versteckte Eingabefeld
refwird auf dem Server aus den Search-Params gefüllt. Ein einfacher Post trägt es ganz ohne Skript mit. - Das Formular hat eine echte
methodundaction. Wenn das Bundle nicht geladen ist, sendet der Browser an die Server-Route und folgt der Weiterleitung. - Nach der Hydration übernimmt
onSubmitund sendetAccept: 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.
| Host | Was hinzuzufügen ist | Das Secret |
|---|---|---|
| Vercel | Das Vite-Plugin nitro() aus dem Paket nitro | Environment Variables in den Projekteinstellungen |
| Netlify | @netlify/vite-plugin-tanstack-start, als netlify() in den Vite-Plugins | Site configuration › Environment variables (Umgebungsvariablen) |
| Cloudflare Workers | @cloudflare/vite-plugin mit viteEnvironment: { name: "ssr" } und einer wrangler.jsonc | wrangler secret put IKIFORM_SECRET_KEY |
{
"$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
- Führe
vite devaus, öffnehttp://localhost:3000/?ref=testund melde dich an. Die Dankeszeile ersetzt das Formular ohne Neuladen. - Öffne im Dashboard Submissions. Die Anmeldung ist da, mit
refauftest. - Schalte JavaScript aus, lade dieselbe URL neu und melde dich erneut an. Die Adressleiste zeigt
?joined=yes, und auch die zweite Anmeldung hatrefauftest. - Sende mit ausgeschaltetem JavaScript
a@bab und prüfe, dass die Seite mit der E-Mail-Meldung und?problem=emailzurückkommt. - 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
vite 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 |
|---|---|
| Ein einfacher Post zeigt rohes JSON | Der 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 failed | IKIFORM_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/waitlist | Start erlaubt eine Handler-Datei pro Pfad. api/waitlist.ts und api/waitlist/index.ts lösen beide darauf auf; behalte eine. |
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
Beide können eine Anmeldung weiterleiten. Eine Server-Route lässt dich die Antwort auf einen einfachen Formular-Post wählen, etwa eine Weiterleitung zurück zur Seite, damit das Formular vor der Hydration funktioniert. Eine Serverfunktion passt zu Formularen, die erst laufen, nachdem React geladen ist.
Lies ?ref= mit validateSearch in der Landing-Route, rendere es in ein verstecktes Eingabefeld und prüfe es auf dem Server erneut, bevor du es weiterleitest. Die Weiterleitung nach einem einfachen Post setzt es wieder in die URL.
Nicht, wenn sie Quelle oder Ergebnis aus dem Query-String rendert, weil eine vorgerenderte Seite ohne einen gebaut wird. Lass diese Route serverseitig gerendert und rendere den Rest vor.
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.