Lista de espera para um novo SaaS

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.

Preet SutharFundador, Ikiform

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:

ItemServer functionServer route
Definida comcreateServerFn().handler()createFileRoute() com server.handlers
Chamada porSeus componentes, por uma chamada RPCQualquer cliente HTTP, inclusive um <form> simples
RespostaSerializada para quem chamouUma Response que você monta
Sem JavaScriptPossível pela .url dela, mas você não controla a respostaVocê 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.

Como a server route do TanStack Start responde a cada tipo de enviorota dalandingpagevalidateSearchlê ?ref=POST/api/waitlistconfere oe-mail,encaminhaIkiformchavesecreta,armazenaJSONo scriptmostra oresultado303 para/?joined=yesou?problem=emailrota dalandingpagerenderizaoresultadoFormDataJSONAccept: jsonenvio simplesComo a server route do TanStack Start responde a cada tipo de enviorota da landingpagevalidateSearch lê?ref=POST/api/waitlistconfere o e-mail,encaminhaIkiformchave secreta,armazenaJSONo script mostra oresultado303 para/?joined=yesou?problem=emailrota dalandingpagerenderiza oresultadoFormDataJSONAccept: jsonenvio simples
  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 cobre os dois. Estes são os arquivos que você mexe:

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

HostO que adicionarO segredo
VercelO plugin Vite nitro() do pacote nitroEnvironment Variables nas configurações do projeto
Netlify@netlify/vite-plugin-tanstack-start, como netlify() nos plugins do ViteEm Site configuration › Environment variables
Cloudflare Workers@cloudflare/vite-plugin com viteEnvironment: { name: "ssr" } e um 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"
}

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.

Pegadinhas

ProblemaO que fazer
Um envio simples mostra JSON cruO 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çãoO 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/waitlistO 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_fieldA definição de campos não lista ref. Adicione-o como na definição acima.
402 plan_requiredO 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

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.