# Monte uma lista de espera em React Router

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

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

Dê à lista de espera uma rota própria com uma `action` que valida o e-mail e o encaminha ao seu backend de formulários com uma chave secreta. Renderize o formulário no seu layout com `useFetcher`, para que uma inscrição feita em qualquer página continue naquela página e seja marcada com o caminho dela; sem JavaScript, o mesmo formulário envia para a rota da lista de espera e a página dela mostra o resultado.

O [guia principal](https://ikiform.com/pt-br/guides/waitlist.md#form) tem uma aba de React Router com o formulário e a `action` dele na rota inicial. Isso serve para um site de uma página. A maioria dos sites SaaS antes do lançamento tem algumas páginas, como preços, um post de blog e a página inicial, e você quer a inscrição em todas elas e saber qual funcionou. Esta página coloca o formulário no layout e envia todas as cópias para uma única action.

## O que muda no React Router

No framework mode, uma action pertence a uma rota, e um `<Form>` envia para a rota em que está. Um formulário no layout está em todas as rotas, então a escolha do componente decide o que acontece no envio:

|   | `<Form method="post">` | `<fetcher.Form action="/waitlist">` |
| --- | --- | --- |
| Para onde envia | A rota atual, a menos que você defina `action` | A action da rota `/waitlist` |
| Depois do envio | Uma navegação. A URL e o histórico mudam | Nenhuma navegação. O visitante continua na página |
| O resultado | O `actionData` da rota que rodou | `fetcher.data`, compartilhado por todos os fetchers com a mesma `key` |
| Sem JavaScript | Um envio normal | Um envio normal para `/waitlist`, que renderiza a própria página |

Um fetcher combina com uma inscrição no rodapé: ninguém quer sair da página de preços para entrar numa lista. Ele também degrada sem problemas. Antes da hidratação, `fetcher.Form` é um `<form>` comum que envia para `/waitlist`, então essa rota precisa de uma página própria para mostrar o resultado.

Diagrama: As inscrições do rodapé de todas as páginas do React Router passam por uma única route action

1. O layout renderiza o formulário da lista de espera em todas as páginas, com um ref oculto feito a partir do caminho da página.
2. Com JavaScript, o fetcher.Form envia para /waitlist sem sair da página. A key do fetcher é compartilhada, então um aviso no header vê o mesmo resultado.
3. Sem JavaScript, o mesmo formulário envia para /waitlist como uma navegação normal.
4. De qualquer jeito, a action de /waitlist confere o e-mail e encaminha a inscrição ao Ikiform com a chave secreta.
5. Um fetcher lê o resultado em fetcher.data. Um envio simples cai na página /waitlist, que o mostra a partir do actionData.

Uma action, quantos formulários você quiser. O caminho diz qual página ganhou a inscrição.

## Configure o projeto

O [guia de servidor](https://ikiform.com/pt-br/docs/guides/server.md) explica como criar o formulário e uma chave secreta. Os arquivos desta página:

`Projeto`:

```text
app/
  root.tsx
  routes.ts
  routes/home.tsx
  routes/pricing.tsx
  routes/waitlist.tsx
  components/waitlist-form.tsx
react-router.config.ts
.env
```

Coloque a chave no `.env` e leia-a de `process.env` na action. Os route modules são divididos para o navegador, mas a `action` só roda no servidor, então a chave nunca vai para o cliente. Nunca a importe num componente.

`.env`:

```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 action envia `email` e `ref`. Se o formulário tiver uma definição de campos na aba **Fields**, ela precisa listar 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

### Registre a rota

`app/routes.ts`:

```ts
import { index, type RouteConfig, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("pricing", "routes/pricing.tsx"),
  route("waitlist", "routes/waitlist.tsx"),
] satisfies RouteConfig;
```

### A rota da lista de espera

`app/routes/waitlist.tsx`:

```tsx
import { data } from "react-router";
import { WaitlistForm } from "../components/waitlist-form";
import type { Route } from "./+types/waitlist";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SOURCE = /^[a-z0-9_-]{1,40}$/;

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const email = String(form.get("email") ?? "").trim();
  if (!EMAIL.test(email)) {
    return data({ ok: false, message: "Enter the email we should send your invite to.", email }, { status: 400 });
  }
  const ref = String(form.get("ref") ?? "");

  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: request.headers.get("origin") ?? "",
    },
    body: JSON.stringify({
      email,
      ref: SOURCE.test(ref) ? ref : "direct",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { ok: true, message: "You're on the list. We'll email you on launch day.", email };
  return data(
    {
      ok: false,
      message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      email,
    },
    { status: response?.status === 429 ? 429 : 400 },
  );
}

export default function Waitlist({ actionData }: Route.ComponentProps) {
  return (
    <main>
      <h1>Join the Acme waitlist</h1>
      <WaitlistForm initial={actionData} />
    </main>
  );
}
```

- `data()` define o status de um erro sem transformá-lo numa response lançada, então o componente ainda renderiza a mensagem.
- Toda resposta traz o e-mail de volta, para o input poder mostrá-lo de novo depois de um erro.
- 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 action não envia nenhum.
- `ref` precisa ser formado por letras minúsculas, dígitos, `-` ou `_`, até 40 caracteres, o mesmo `max_length` da definição. Qualquer outra coisa é armazenada como `direct`.
- O default export é o plano B sem JavaScript. Ele renderiza o mesmo formulário com o resultado da action.

### O formulário compartilhado

`app/components/waitlist-form.tsx`:

```tsx
import { useFetcher, useLocation } from "react-router";

interface Outcome {
  ok: boolean;
  message: string;
  email: string;
}

export function sourceFor(pathname: string) {
  const slug = pathname.replace(/^\/+|\/+$/g, "").replace(/\//g, "-").toLowerCase();
  return (slug || "home").slice(0, 40);
}

export function WaitlistForm({ initial }: { initial?: Outcome }) {
  const fetcher = useFetcher<Outcome>({ key: "waitlist" });
  const { pathname } = useLocation();
  const outcome = fetcher.data ?? initial;
  const pending = fetcher.state !== "idle";

  if (outcome?.ok) return <p role="status">{outcome.message}</p>;

  return (
    <fetcher.Form method="post" action="/waitlist">
      <label>
        Email
        <input name="email" type="email" autoComplete="email" required defaultValue={outcome?.email} />
      </label>
      <input type="hidden" name="ref" value={sourceFor(pathname)} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <p role="alert">{outcome?.message}</p>
      <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
    </fetcher.Form>
  );
}

export function JoinedNote() {
  const fetcher = useFetcher<Outcome>({ key: "waitlist" });
  return fetcher.data?.ok ? <span>You're on the list</span> : <a href="#waitlist">Join the waitlist</a>;
}
```

- `key: "waitlist"` transforma todos os fetchers com essa key num único fetcher compartilhado. O `JoinedNote` no header lê o mesmo `fetcher.data`, então muda no instante em que o formulário do rodapé dá certo.
- `sourceFor` transforma `/blog/launch` em `blog-launch` e `/` em `home`. O caminho é conhecido no servidor, então funciona também em páginas pré-renderizadas.
- `fetcher.state` é `submitting` enquanto o envio roda, depois `loading` enquanto o React Router revalida, então o botão continua desativado até os dois terminarem.
- O label envolve o input, então as cópias do hero e do rodapé numa mesma página não compartilham um `id`.

Renderize `<WaitlistForm />` no `Layout` do `app/root.tsx`, dentro de um footer com `id="waitlist"`, e `<JoinedNote />` no seu header. O honeypot vai junto e chega intacto ao Ikiform. Envios com a chave secreta pulam a verificação humana do Ikiform, então ligar o **Spam check** não acrescenta nada para este formulário.

### Evite recarregar todos os loaders

Depois de uma action, o React Router recarrega os dados de todas as rotas da página. Uma inscrição não muda nenhum deles. Adicione isto às rotas cujos loaders são lentos ou custam dinheiro:

`app/routes/pricing.tsx`:

```ts
import type { ShouldRevalidateFunctionArgs } from "react-router";

export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
  if (formAction === "/waitlist") return false;
  return defaultShouldRevalidate;
}
```

## Renderização e deploy

Páginas de marketing não mudam por visitante, então pré-renderize-as e deixe o servidor para a action:

`react-router.config.ts`:

```ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: true,
  prerender: ["/", "/pricing"],
} satisfies Config;
```

Mantenha `ssr: true`. Com `ssr: false` não existe servidor em runtime, e o React Router falha o build para qualquer rota que exporte uma `action`. Com `ssr: true`, `/` e `/pricing` são gravadas como HTML no build, e `/waitlist` continua rodando no servidor.

| Host | O que adicionar | Lendo a chave |
| --- | --- | --- |
| Vercel | `@vercel/react-router`, com `presets: [vercelPreset()]` no `react-router.config.ts` | `process.env`, definida em **Environment Variables** |
| Netlify | `@netlify/vite-plugin-react-router`, como `netlifyReactRouter()` nos plugins do Vite | `process.env`, definida em **Environment variables** |
| Cloudflare Workers | `@cloudflare/vite-plugin`, como `cloudflare()` antes de `reactRouter()` | `env.IKIFORM_SECRET_KEY` de `cloudflare:workers`, definida com `wrangler secret put` |

Na Cloudflare, use `import { env } from "cloudflare:workers"` e leia a chave dali em vez de `process.env`. No React Router 8, o `context` da action é um `RouterContextProvider`, então o padrão `context.cloudflare.env` da v7 não vale mais. O resto da action continua igual.

## Confira se funciona

1. Rode `react-router dev`, abra `http://localhost:5173/pricing` e inscreva-se no rodapé. A frase de agradecimento substitui o formulário, e o aviso do header muda junto.
2. Abra **Submissions** no painel. A inscrição tem `ref` definido como `pricing`.
3. Desligue o JavaScript e inscreva-se pela página inicial. Você cai em `/waitlist` com a frase de agradecimento, e a inscrição tem `ref` definido como `home`.
4. Envie `a@b` pelo rodapé. O erro aparece abaixo do input com o endereço ainda digitado.
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 `react-router 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 |
| --- | --- |
| Uma inscrição no rodapé navega para `/waitlist` mesmo com JavaScript ligado | O formulário é um `<Form>` ou um `<form>` simples, não `fetcher.Form`. Use o fetcher e não devolva um `redirect()` da action; um redirect vindo da action de um fetcher navega. |
| O build falha com um erro sobre exports de `action` | `ssr` está como `false` no `react-router.config.ts`. Mude para `true`, ou a lista de espera fica sem servidor para rodar. |
| O `ref` de toda inscrição é `waitlist` | Os envios vêm da própria página `/waitlist`. Essa é a página de plano B; os links devem apontar para as suas páginas de verdade. |
| `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

### Como envio um formulário do React Router sem navegar?

Use o useFetcher e renderize o fetcher.Form com a action da rota que o trata. O visitante continua na página e o resultado chega em fetcher.data. Antes da hidratação, ele envia como um formulário normal.

### Páginas pré-renderizadas do React Router podem ter um formulário funcionando?

Sim, quando ssr é true. Páginas pré-renderizadas são servidas como HTML, e os formulários delas enviam para uma action que roda no servidor. Com ssr definido como false, actions não são permitidas.

### Como sei de qual página veio uma inscrição no React Router?

Coloque o caminho da página num input oculto ref, como o sourceFor faz acima. O caminho é conhecido no servidor e no cliente, então funciona em páginas pré-renderizadas e antes da hidratação.

### 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 TanStack Start](https://ikiform.com/pt-br/guides/waitlist/tanstack-start.md): 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.
- [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.
