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

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/waitlist/tanstack-start

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

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

Diagrama: Como a server route do TanStack Start responde a cada tipo de envio

1. A rota da landing page lê ?ref= via validateSearch e o renderiza num input oculto.
2. 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.
3. A rota confere o e-mail e a tag de origem, e então encaminha a inscrição ao Ikiform com a chave secreta.
4. 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.
5. 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.

Um handler, duas respostas. A URL leva o resultado para o visitante sem JavaScript.

## Configure o projeto

Crie antes o formulário e uma chave secreta; o [guia de servidor](https://ikiform.com/pt-br/docs/guides/server.md) cobre os dois. Estes são os arquivos que você mexe:

`Projeto`:

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

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

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

> As versões no servidor enviam com uma chave secreta, então o Ikiform pula **Allowed domains** e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o [limite de taxa](https://ikiform.com/pt-br/docs/rate-limits.md) Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha `IKIFORM_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:

`Campos`:

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

## Construa

### Códigos de erro, não texto de erro

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

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

`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()` lê as duas codificações: o `multipart/form-data` que o `fetch` envia com um corpo `FormData` e 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 o `nodejs_compat` ligado e uma compatibility date de 2025-04-01 ou posterior, como no `wrangler.jsonc` abaixo.
- O header `Origin` do navegador é repassado porque o Ikiform conta os envios de teste gratuitos de `localhost` por ele, e o `fetch` da própria rota não envia nenhum.
- `ref` precisa ser formado por letras minúsculas, dígitos, `-` ou `_`, até 40 caracteres, batendo com o `max_length` da definição. Qualquer outra coisa é armazenada como `direct`.
- `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

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

- O `validateSearch` transforma a query crua num objeto tipado. Valores desconhecidos viram `undefined`, 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 `method` e `action` de verdade. Se o bundle não carregou, o navegador envia para a server route e segue o redirecionamento.
- Depois da hidratação, o `onSubmit` assume e envia `Accept: 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` |

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

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

1. Rode `vite dev`, abra `http://localhost:3000/?ref=test` e inscreva-se. A frase de agradecimento substitui o formulário sem recarregar.
2. Abra **Submissions** no painel. A inscrição está lá com `ref` definido como `test`.
3. 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 tem `ref` definido como `test`.
4. Com o JavaScript desligado, envie `a@b` e confirme que a página volta com a mensagem de e-mail e `?problem=email`.
5. 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.
6. Envios do `vite dev` trazem a origem `localhost`, 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 de `localhost`. 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](https://ikiform.com/pt-br/pricing).

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

### Um formulário em TanStack Start deve usar uma server function ou uma server route?

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.

### Como mantenho a origem da inscrição no TanStack Start?

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.

### A landing page em TanStack Start pode ser pré-renderizada?

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.

### O Ikiform é gratuito?

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.

## Mais guias

- [Lista de espera para um novo SaaS](https://ikiform.com/pt-br/guides/waitlist.md): Monte uma lista de espera pré-lançamento na sua própria landing page: o formulário, proteção contra spam, duplicados, e-mails de confirmação e uma exportação limpa no dia do lançamento.
- [Lista de espera com Next.js](https://ikiform.com/pt-br/guides/waitlist/nextjs.md): Uma lista de espera no App Router do Next.js: uma server action que encaminha as inscrições com chave secreta, useActionState, agradecimento otimista e limite por visitante.
- [Lista de espera com React Router](https://ikiform.com/pt-br/guides/waitlist/react-router.md): Uma lista de espera em React Router no rodapé de todas as páginas: um fetcher envia para uma única route action, marca cada inscrição com a página e funciona sem JavaScript.
- [Lista de espera em HTML puro](https://ikiform.com/pt-br/guides/waitlist/html.md): Uma página de lista de espera completa em HTML puro, sem build, que funciona com o JavaScript desligado, mostra um agradecimento na própria página quando carrega e tem plano B quando precisa.
- [Lista para lançamento no Product Hunt](https://ikiform.com/pt-br/guides/waitlist/product-hunt.md): Colete uma lista semanas antes do Product Hunt, marque cada inscrição pelo canal e envie o e-mail do dia do lançamento em duas ondas, no ritmo da virada do horário do Pacífico.
- [Inscrição para beta com triagem](https://ikiform.com/pt-br/guides/waitlist/beta-signup.md): Um formulário de candidatura ao beta com perguntas de triagem que o servidor exige, e um script que pontua os candidatos e escolhe cada turma da lista.
