Waitlist for a new SaaS

Waitlist for a new SaaS7 min read

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.

Preet SutharFounder, Ikiform

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

ItemServer functionServer route
Defined withcreateServerFn().handler()createFileRoute() with server.handlers
Called byYour components, through an RPC callAny HTTP client, including a plain <form>
AnswerSerialized for the callerA Response you build
Without JavaScriptPossible through its .url, but you don't control the answerYou 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.

How the TanStack Start server route answers each kind of postlandingroutevalidateSearchreads?ref=POST/api/waitlistchecksemail,forwardsIkiformsecret key,stores itJSONthe scriptshows theresult303 to /?joined=yesor?problem=emaillandingrouterendersthe resultFormDataJSONAccept: jsonplain postHow the TanStack Start server route answers each kind of postlanding routevalidateSearch reads?ref=POST/api/waitlistchecks email, forwardsIkiformsecret key, stores itJSONthe script shows theresult303 to /?joined=yesor?problem=emaillandingrouterenders theresultFormDataJSONAccept: jsonplain 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 covers both. These are the files you touch:

Project
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
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 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
{
  "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
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
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
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.

HostWhat to addThe secret
VercelThe nitro() Vite plugin from the nitro packageEnvironment Variables in the project settings
Netlify@netlify/vite-plugin-tanstack-start, as netlify() in the Vite pluginsSite configuration › Environment variables
Cloudflare Workers@cloudflare/vite-plugin with viteEnvironment: { name: "ssr" } and a 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 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.

Gotchas

ProblemWhat to do
A plain post shows raw JSONThe 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 productionIKIFORM_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/waitlistStart allows one handler file per path. api/waitlist.ts and api/waitlist/index.ts both resolve to it; keep one.
422 with unknown_fieldThe field definition doesn't list ref. Add it as in the definition above.
402 plan_requiredThe 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

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.