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.
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:
| Item | 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.
- The landing route reads ?ref= through validateSearch and renders it into a hidden input.
- 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.
- The route checks the email and the source tag, then forwards the signup to Ikiform with the secret key.
- 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.
- 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.
Set up the project
Create the form and a secret key first; the server guide covers both. These are the files you touch:
src/
routes/
__root.tsx
index.tsx
api/waitlist.ts
waitlist-messages.ts
vite.config.ts
.env.localStore 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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:
{
"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
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
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: themultipart/form-datathatfetchsends with aFormDatabody, and the urlencoded body of a plain post.process.envis read inside the handler, at request time. On Cloudflare Workers it holds your secrets only withnodejs_compaton and a compatibility date of 2025-04-01 or later, as in thewrangler.jsoncbelow.- The browser's
Originheader is passed on because Ikiform counts freelocalhosttest submissions by it, and the route's ownfetchsends none. refmust be lowercase letters, digits,-or_, up to 40 characters, matching themax_lengthin the definition. Anything else is stored asdirect.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
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>
);
}validateSearchturns the raw query into a typed object. Unknown values becomeundefined, so a broken link renders the plain form.- The hidden
refinput is filled on the server from the search params. A plain post carries it with no script at all. - The form has a real
methodandaction. If the bundle hasn't loaded, the browser posts to the server route and follows the redirect. - Once hydrated,
onSubmittakes over and sendsAccept: 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 |
{
"$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
- Run
vite dev, openhttp://localhost:3000/?ref=testand sign up. The thank-you line replaces the form without a reload. - Open Submissions in the dashboard. The signup is there with
refset totest. - Turn JavaScript off, reload the same URL and sign up again. The address bar shows
?joined=yes, and the second signup also hasrefset totest. - With JavaScript off, submit
a@band confirm the page comes back with the email message and?problem=email. - 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. - Posts from
vite devcarry alocalhostorigin, 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 fromlocalhost. 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
| 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
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.
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.
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.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.