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

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/waitlist/tanstack-start

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](https://ikiform.com/de/guides/waitlist.md#form) 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:

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

Diagramm: Wie die TanStack-Start-Server-Route jede Art von Post beantwortet

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](https://ikiform.com/de/docs/guides/server.md) behandelt beides. Das sind die Dateien, die du anfasst:

`Projekt`:

```text
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`:

```bash
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](https://ikiform.com/de/docs/rate-limits.md) 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`:

```json
{
  "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`:

```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`:

```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`:

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

| 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` |

`wrangler.jsonc`:

```json
{
  "$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](https://ikiform.com/de/pricing).

## 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

### Sollte ein TanStack-Start-Formular eine Serverfunktion oder eine Server-Route nutzen?

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.

### Wie behalte ich die Anmeldequelle in TanStack Start?

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.

### Kann die TanStack-Start-Landingpage vorgerendert werden?

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.

### Ist Ikiform kostenlos?

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.

## Weitere Anleitungen

- [Warteliste für ein neues SaaS](https://ikiform.com/de/guides/waitlist.md): Baue eine Pre-Launch-Warteliste auf deiner eigenen Landingpage: das Formular, Spamschutz, Duplikate, Bestätigungs-E-Mails und ein sauberer Export am Launch-Tag.
- [Warteliste mit Next.js](https://ikiform.com/de/guides/waitlist/nextjs.md): Eine Warteliste mit dem Next.js App Router: eine Server Action, die Anmeldungen mit Secret Key weiterleitet, useActionState, ein optimistisches Danke und ein Limit pro Besucher.
- [React-Router-Warteliste](https://ikiform.com/de/guides/waitlist/react-router.md): 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.
- [Warteliste in reinem HTML](https://ikiform.com/de/guides/waitlist/html.md): Eine komplette Wartelisten-Seite in reinem HTML ohne Build, die ohne JavaScript funktioniert, nach dem Laden ein Danke inline zeigt und bei Bedarf zurückfällt.
- [Launch-Liste für Product Hunt](https://ikiform.com/de/guides/waitlist/product-hunt.md): Sammle Wochen vor Product Hunt eine Launch-Liste, markiere jede Anmeldung nach Kanal und sende die Launch-E-Mail in zwei Wellen, abgestimmt auf den Reset nach Pacific Time.
- [Beta-Anmeldung mit Auswahl](https://ikiform.com/de/guides/waitlist/beta-signup.md): Ein Beta-Bewerbungsformular mit Auswahlfragen, die der Server durchsetzt, und ein Skript, das Bewerber bewertet und jede Kohorte aus der Liste auswählt.
