Lista de espera para um novo SaaS8 min de leitura
Monte uma lista de espera em TanStack Start
Uma lista de espera em TanStack Start: uma server route responde tanto a um envio de formulário simples quanto ao fetch, e o validateSearch leva a origem e o resultado na URL.
Adicione uma server route em /api/waitlist que valida o e-mail, encaminha a inscrição ao seu backend de formulários com uma chave secreta e responde JSON ao fetch ou um redirecionamento 303 a um envio de formulário simples. A rota da landing page lê ?ref=, ?joined= e ?problem= via validateSearch, então a tag de origem e o resultado sobrevivem com ou sem JavaScript.
O guia principal tem uma aba de TanStack Start baseada em createServerFn e useServerFn. É curta, mas o formulário só funciona depois que o React hidratou, e a origem de cada inscrição é uma string fixa. Esta página monta a versão que você divulgaria no dia do lançamento: ela envia antes da hidratação, marca cada inscrição a partir do link de onde veio e mantém o resultado na URL.
O que muda no TanStack Start
O Start oferece duas formas de rodar código no servidor, e elas se comportam de forma diferente num formulário:
| Item | Server function | Server route |
|---|---|---|
| Definida com | createServerFn().handler() | createFileRoute() com server.handlers |
| Chamada por | Seus componentes, por uma chamada RPC | Qualquer cliente HTTP, inclusive um <form> simples |
| Resposta | Serializada para quem chamou | Uma Response que você monta |
| Sem JavaScript | Possível pela .url dela, mas você não controla a resposta | Você escolhe: JSON, ou um redirecionamento de volta para a página |
Um envio de lista de espera vem de dois tipos de cliente: um navegador rodando o seu script e um navegador que ainda não o carregou. Uma server route consegue responder a cada um do jeito que ele espera, a partir de um único handler. O header Accept diferencia os dois: o script pede application/json, e um envio de formulário simples não.
- A rota da landing page lê ?ref= via validateSearch e o renderiza num input oculto.
- O formulário envia o FormData dele para a server route, com fetch quando o script carregou, ou como envio simples quando não carregou.
- A rota confere o e-mail e a tag de origem, e então encaminha a inscrição ao Ikiform com a chave secreta.
- Um envio por fetch pediu JSON, então recebe { ok } ou uma mensagem, e o componente coloca a frase de agradecimento ou o erro no lugar.
- Um envio simples recebe um 303 de volta para a landing page, com ?joined=yes ou um código ?problem=, e o mesmo ref. O validateSearch os lê, e a página renderiza o resultado.
Configure o projeto
Crie antes o formulário e uma chave secreta; o guia de servidor cobre os dois. Estes são os arquivos que você mexe:
src/
routes/
__root.tsx
index.tsx
api/waitlist.ts
waitlist-messages.ts
vite.config.ts
.env.localGuarde a chave em .env.local, que o Start carrega em desenvolvimento e que fica fora do git. O código do servidor a lê de process.env. Nunca dê a ela o prefixo VITE_; isso a coloca no código do navegador.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.A rota envia email e ref. Se você adicionar uma definição de campos na aba Fields do formulário, liste os dois:
{
"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 } }
]
}Construa
Códigos de erro, não texto de erro
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;
}O redirecionamento coloca um código curto na URL, nunca a mensagem em si. Assim, um link com ?problem= só consegue mostrar uma das suas três frases, não qualquer texto que um desconhecido digite numa URL.
A 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()lê as duas codificações: omultipart/form-dataque ofetchenvia com um corpoFormDatae o corpo urlencoded de um envio simples.- O
process.envé lido dentro do handler, no momento da requisição. No Cloudflare Workers, ele só contém os seus secrets com onodejs_compatligado e uma compatibility date de 2025-04-01 ou posterior, como nowrangler.jsoncabaixo. - O header
Origindo navegador é repassado porque o Ikiform conta os envios de teste gratuitos delocalhostpor ele, e ofetchda própria rota não envia nenhum. refprecisa ser formado por letras minúsculas, dígitos,-ou_, até 40 caracteres, batendo com omax_lengthda definição. Qualquer outra coisa é armazenada comodirect.Response.redirect(back, 303)diz ao navegador para carregar a landing page com um GET, para que um refresh não envie a inscrição duas vezes.
A rota da landing page
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>
);
}- O
validateSearchtransforma a query crua num objeto tipado. Valores desconhecidos viramundefined, então um link quebrado renderiza o formulário simples. - O input oculto
refé preenchido no servidor a partir dos search params. Um envio simples o leva sem script nenhum. - O formulário tem
methodeactionde verdade. Se o bundle não carregou, o navegador envia para a server route e segue o redirecionamento. - Depois da hidratação, o
onSubmitassume e enviaAccept: application/json, para o visitante continuar na página.
O honeypot vai no mesmo FormData e chega intacto ao Ikiform, então os bots que o preenchem são armazenados como spam. Envios com a chave secreta pulam a verificação humana do Ikiform, então ligar o Spam check não acrescenta nada aqui; esta rota é a barreira.
Renderização e deploy
Deixe a rota da landing page renderizada no servidor, que é o padrão do Start. Ela lê a query string para renderizar o ref e o resultado, e uma página pré-renderizada é gerada uma vez, sem query. Se você ligar o prerender no plugin tanstackStart() para outras páginas, deixe o / de fora.
| Host | O que adicionar | O segredo |
|---|---|---|
| Vercel | O plugin Vite nitro() do pacote nitro | Environment Variables nas configurações do projeto |
| Netlify | @netlify/vite-plugin-tanstack-start, como netlify() nos plugins do Vite | Em Site configuration › Environment variables |
| Cloudflare Workers | @cloudflare/vite-plugin com viteEnvironment: { name: "ssr" } e um 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"
}O nodejs_compat deixa o process.env disponível no Workers. Sem ele, a chave fica ausente e todo envio falha com failed.
Confira se funciona
- Rode
vite dev, abrahttp://localhost:3000/?ref=teste inscreva-se. A frase de agradecimento substitui o formulário sem recarregar. - Abra Submissions no painel. A inscrição está lá com
refdefinido comotest. - Desligue o JavaScript, recarregue a mesma URL e inscreva-se de novo. A barra de endereço mostra
?joined=yes, e a segunda inscrição também temrefdefinido comotest. - Com o JavaScript desligado, envie
a@be confirme que a página volta com a mensagem de e-mail e?problem=email. - Envie com o honeypot preenchido,
_gotcha=bot. Ele ainda recebe uma resposta de sucesso, mas o envio é armazenado como spam e não dispara e-mail nem webhook. - Envios do
vite devtrazem a origemlocalhost, então contam como envios de teste. O Ikiform não tem plano gratuito. Testar é grátis: um workspace sem plano aceita 50 envios de teste por mês a partir delocalhost. Para entrar no ar, escolha o Pro a partir de US$ 6/mês para 500 envios, ou pague uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram. Veja os preços.
Pegadinhas
| Problema | O que fazer |
|---|---|
| Um envio simples mostra JSON cru | O navegador enviou application/json no header Accept, o que envios de formulário normais nunca fazem. Confira se nenhuma extensão ou proxy o reescreve, ou faça o fetch enviar um header personalizado e use-o para diferenciar. |
Todo envio responde failed em produção | O IKIFORM_SECRET_KEY não está definido no host. No Workers, confira também se o nodejs_compat está ligado e se a compatibility date é 2025-04-01 ou posterior, ou o process.env fica vazio. |
Dois arquivos reivindicam /api/waitlist | O Start permite um arquivo de handler por caminho. api/waitlist.ts e api/waitlist/index.ts resolvem para o mesmo; mantenha um. |
422 com unknown_field | A definição de campos não lista ref. Adicione-o como na definição acima. |
402 plan_required | O workspace não tem plano e o envio veio de um site publicado. Ele fica retido, não perdido. Escolha um plano ou compre um pacote em Settings › Plan and usage e os envios retidos chegam. |
Perguntas
Qualquer uma consegue encaminhar uma inscrição. Uma server route deixa você escolher a resposta para um envio de formulário simples, como um redirecionamento de volta para a página, então o formulário funciona antes da hidratação. Uma server function serve para formulários que só rodam depois que o React carregou.
Leia ?ref= com validateSearch na rota da landing page, renderize-o num input oculto e confira de novo no servidor antes de encaminhar. O redirecionamento depois de um envio simples o coloca de volta na URL.
Não se ela renderiza a origem ou o resultado a partir da query string, porque uma página pré-renderizada é gerada sem query. Mantenha essa rota renderizada no servidor e pré-renderize o resto.
Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.