# Build a waitlist in TanStack Start

> A TanStack Start waitlist: one server route answers both a plain form post and fetch, and validateSearch carries the source and the result in the URL.

Published 2026-10-25 · https://ikiform.com/guides/waitlist/tanstack-start

Add a server route at `/api/waitlist` that validates the email, forwards the signup to your form backend with a secret key, and answers JSON to `fetch` or a `303` redirect to a plain form post. The landing route reads `?ref=`, `?joined=` and `?problem=` through `validateSearch`, so the source tag and the result survive with or without JavaScript.

The [main guide](https://ikiform.com/guides/waitlist.md#form) has a TanStack Start tab built on `createServerFn` and `useServerFn`. It's short, but the form only works once React has hydrated, and the source of each signup is a fixed string. This page builds the version you'd share on launch day: it posts before hydration, tags each signup from the link it came from, and keeps the result in the URL.

## What's different in TanStack Start

Start gives you two ways to run code on the server, and they behave differently for a form:

|   | Server function | Server route |
| --- | --- | --- |
| Defined with | `createServerFn().handler()` | `createFileRoute()` with `server.handlers` |
| Called by | Your components, through an RPC call | Any HTTP client, including a plain `<form>` |
| Answer | Serialized for the caller | A `Response` you build |
| Without JavaScript | Possible through its `.url`, but you don't control the answer | You choose: JSON, or a redirect back to the page |

A waitlist post comes from two kinds of client: a browser running your script, and a browser that hasn't loaded it yet. A server route can answer each the way it expects, from one handler. The `Accept` header tells them apart: the script asks for `application/json`, and a plain form post doesn't.

Diagram: How the TanStack Start server route answers each kind of post

1. The landing route reads ?ref= through validateSearch and renders it into a hidden input.
2. The form posts its FormData to the server route, with fetch when the script has loaded, or as a plain post when it hasn't.
3. The route checks the email and the source tag, then forwards the signup to Ikiform with the secret key.
4. A fetch post asked for JSON, so it gets { ok } or a message, and the component swaps in the thank-you line or the error.
5. A plain post gets a 303 back to the landing page, with ?joined=yes or a ?problem= code, and the same ref. validateSearch reads them, and the page renders the result.

One handler, two answers. The URL carries the result for the visitor without JavaScript.

## Set up the project

Create the form and a secret key first; the [server guide](https://ikiform.com/docs/guides/server.md) covers both. These are the files you touch:

`Project`:

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

Store the key in `.env.local`, which Start loads in development and which stays out of git. Server code reads it from `process.env`. Never give it a `VITE_` prefix; that bundles it into the browser code.

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

> The server-side versions post with a secret key, so Ikiform skips **Allowed domains** and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `IKIFORM_SECRET_KEY` in server-only environment variables.

The route sends `email` and `ref`. If you add a field definition on the form's **Fields** tab, list both:

`Fields`:

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

## Build it

### Error codes, not error text

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

The redirect puts a short code in the URL, never the message itself. A link with `?problem=` can then only show one of your three sentences, not any text a stranger types into a URL.

### The 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()` reads both encodings: the `multipart/form-data` that `fetch` sends with a `FormData` body, and the urlencoded body of a plain post.
- `process.env` is read inside the handler, at request time. On Cloudflare Workers it holds your secrets only with `nodejs_compat` on and a compatibility date of 2025-04-01 or later, as in the `wrangler.jsonc` below.
- The browser's `Origin` header is passed on because Ikiform counts free `localhost` test submissions by it, and the route's own `fetch` sends none.
- `ref` must be lowercase letters, digits, `-` or `_`, up to 40 characters, matching the `max_length` in the definition. Anything else is stored as `direct`.
- `Response.redirect(back, 303)` tells the browser to load the landing page with a GET, so a refresh doesn't post the signup twice.

### The 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` turns the raw query into a typed object. Unknown values become `undefined`, so a broken link renders the plain form.
- The hidden `ref` input is filled on the server from the search params. A plain post carries it with no script at all.
- The form has a real `method` and `action`. If the bundle hasn't loaded, the browser posts to the server route and follows the redirect.
- Once hydrated, `onSubmit` takes over and sends `Accept: application/json`, so the visitor stays on the page.

The honeypot travels in the same `FormData` and goes to Ikiform untouched, so bots that fill it are stored as spam. Posts with the secret key skip Ikiform's human check, so turning **Spam check** on adds nothing here; this route is the gate.

## Rendering and deploy

Leave the landing route server-rendered, which is Start's default. It reads the query string to render `ref` and the result, and a prerendered page is built once with no query. If you turn on `prerender` in the `tanstackStart()` plugin for other pages, leave `/` out.

| Host | What to add | The secret |
| --- | --- | --- |
| Vercel | The `nitro()` Vite plugin from the `nitro` package | **Environment Variables** in the project settings |
| Netlify | `@netlify/vite-plugin-tanstack-start`, as `netlify()` in the Vite plugins | **Site configuration › Environment variables** |
| Cloudflare Workers | `@cloudflare/vite-plugin` with `viteEnvironment: { name: "ssr" }` and a `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` makes `process.env` available on Workers. Without it, the key is missing and every post fails with `failed`.

## Check it works

1. Run `vite dev`, open `http://localhost:3000/?ref=test` and sign up. The thank-you line replaces the form without a reload.
2. Open **Submissions** in the dashboard. The signup is there with `ref` set to `test`.
3. Turn JavaScript off, reload the same URL and sign up again. The address bar shows `?joined=yes`, and the second signup also has `ref` set to `test`.
4. With JavaScript off, submit `a@b` and confirm the page comes back with the email message and `?problem=email`.
5. Post with the honeypot filled in, `_gotcha=bot`. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.
6. Posts from `vite dev` carry a `localhost` origin, so they count as test submissions. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from `localhost`. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See [pricing](https://ikiform.com/pricing).

## Gotchas

| Problem | What to do |
| --- | --- |
| A plain post shows raw JSON | The browser sent `application/json` in its `Accept` header, which normal form posts never do. Check that no extension or proxy rewrites it, or match on `fetch` sending a custom header instead. |
| Every post answers `failed` in production | `IKIFORM_SECRET_KEY` isn't set on the host. On Workers, also check that `nodejs_compat` is on and the compatibility date is 2025-04-01 or later, or `process.env` stays empty. |
| Two files claim `/api/waitlist` | Start allows one handler file per path. `api/waitlist.ts` and `api/waitlist/index.ts` both resolve to it; keep one. |
| `422` with `unknown_field` | The field definition doesn't list `ref`. Add it as in the definition above. |
| `402 plan_required` | The workspace has no plan and the post came from a live site. It is held, not lost. Pick a plan or buy a pack under **Settings › Plan and usage** and held submissions arrive. |

## Questions

### Should a TanStack Start form use a server function or a server route?

Either can forward a signup. A server route lets you choose the answer for a plain form post, such as a redirect back to the page, so the form works before hydration. A server function suits forms that only run after React has loaded.

### How do I keep the signup source in TanStack Start?

Read ?ref= with validateSearch on the landing route, render it into a hidden input, and check it again on the server before you forward it. The redirect after a plain post puts it back in the URL.

### Can the TanStack Start landing page be prerendered?

Not if it renders the source or the result from the query string, because a prerendered page is built without one. Keep that route server-rendered and prerender the rest.

### Is Ikiform free?

No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.

## More guides

- [Waitlist for a new SaaS](https://ikiform.com/guides/waitlist.md): Build a pre-launch waitlist on your own landing page: the form, spam protection, duplicates, confirmation emails and a clean export on launch day.
- [Next.js waitlist](https://ikiform.com/guides/waitlist/nextjs.md): A Next.js App Router waitlist: a server action that forwards signups with a secret key, useActionState, an optimistic thank-you and a per-visitor limit.
- [React Router waitlist](https://ikiform.com/guides/waitlist/react-router.md): A React Router waitlist in the footer of every page: a fetcher posts to one route action, tags each signup with its page and works without JavaScript.
- [Plain HTML waitlist](https://ikiform.com/guides/waitlist/html.md): A complete no-build waitlist page in plain HTML that works with JavaScript off, shows an inline thank-you when it loads, and falls back when it must.
- [Product Hunt launch list](https://ikiform.com/guides/waitlist/product-hunt.md): Collect a launch list weeks before Product Hunt, tag each signup by channel, and send the launch-day email in two waves timed to the Pacific Time reset.
- [Beta signup with screening](https://ikiform.com/guides/waitlist/beta-signup.md): A beta application form with screening questions the server enforces, and a script that scores applicants and picks each cohort from the list.
