# Como montar uma lista de espera em Next.js com uma server action

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

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

Escreva uma server action que confere o e-mail, aplica um limite por visitante e encaminha a inscrição ao seu backend de formulários com uma chave secreta. Chame-a via `useActionState` para que os erros voltem como state, e envolva-a com `useOptimistic` para que a frase de agradecimento apareça no instante em que o botão é clicado e seja desfeita se o envio falhar.

O [guia principal](https://ikiform.com/pt-br/guides/waitlist.md#form) tem uma aba curta de Next.js: uma server action e `useActionState`. Isso basta para uma landing page tranquila. Esta página vai além, para uma lista de espera que você está prestes a divulgar amplamente: um agradecimento instantâneo, mensagens de erro honestas e um limite por visitante, que um envio pelo servidor perderia de outra forma.

## O que uma server action muda

Uma server action mantém a chave secreta no seu servidor e permite conferir o e-mail antes de qualquer coisa sair do seu app. Ela também faz todo envio sair do IP do seu servidor, e isso tem três efeitos:

|   | Navegador envia ao Ikiform | Server action com chave secreta |
| --- | --- | --- |
| Quem o Ikiform vê | O IP de cada visitante | O IP do seu servidor, para todos os visitantes |
| Limite de taxa por visitante | O do Ikiform, por IP | Por sua conta, mostrado abaixo |
| Domínios permitidos e a verificação humana | Rodam | Pulados, porque a chave prova que o envio é seu |
| Quem pode chamar | Qualquer um, a partir dos seus domínios permitidos | Qualquer um que consiga fazer POST na sua página |

Essa última linha importa. O Next.js trata uma server action como um endpoint público: qualquer um pode chamá-la com um POST forjado, não só o seu formulário. Com a chave secreta pulando as verificações de origem do Ikiform, a sua action é a única barreira, então ela precisa de um limite próprio.

Diagrama: Como uma inscrição passa pela server action do Next.js

1. O visitante clica no botão. O useOptimistic troca o formulário pela frase de agradecimento na hora.
2. A server action confere o e-mail e conta as tentativas recentes do visitante pelo IP.
3. Dentro do limite, ela envia a inscrição ao Ikiform com a chave secreta.
4. O resultado vira o action state. Em caso de sucesso, o agradecimento fica; num erro, o formulário volta com a mensagem e o e-mail digitado.
5. Acima do limite, nada é enviado e o estado de erro volta na hora.

A frase otimista dura só enquanto a action roda. O action state decide o que fica na tela.

## Construa

### A chave secreta

No painel, abra **API keys**, crie uma chave secreta com o escopo `submissions:write` e guarde-a como variável só do servidor. Nunca coloque o prefixo `NEXT_PUBLIC_` nela.

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

### Um limite por visitante

Cinco inscrições por minuto por IP é generoso para uma pessoa e inútil para um script. Este contador fica na memória do servidor. `server-only` faz o build falhar se um client component o importar.

`lib/rate-limit.ts`:

```ts
import "server-only";

const WINDOW_MS = 60_000;
const LIMIT = 5;
const hits = new Map<string, number[]>();

export function allow(key: string, now = Date.now()) {
  const recent = (hits.get(key) ?? []).filter((time) => now - time < WINDOW_MS);
  const allowed = recent.length < LIMIT;
  if (allowed) recent.push(now);
  if (hits.size > 10_000) hits.clear();
  hits.set(key, recent);
  return allowed;
}
```

- A memória não é compartilhada entre instâncias do servidor. Num único servidor ou container Node, isto é exato. Em hospedagem serverless, cada instância guarda a própria contagem, então trate isto como um quebra-molas e leve o contador para um armazenamento compartilhado, como o Redis, quando o tráfego crescer.
- Só confie no header de IP que o seu host define. A Vercel define `x-forwarded-for`; atrás da Cloudflare, leia `cf-connecting-ip`. Um header que o próprio visitante pode definir não limita nada.

### A server action

`app/waitlist/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { allow } from "@/lib/rate-limit";

export interface JoinState {
  status: "idle" | "joined" | "error";
  message: string;
  email?: string;
}

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export async function joinWaitlist(_previous: JoinState, formData: FormData): Promise<JoinState> {
  const email = String(formData.get("email") ?? "").trim();
  if (!EMAIL.test(email)) return { status: "error", message: "Enter the email we should send your invite to.", email };

  const incoming = await headers();
  const visitor = incoming.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
  if (!allow(visitor)) return { status: "error", message: "Too many tries from this connection. Wait a minute.", email };

  let response: Response;
  try {
    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: incoming.get("origin") ?? "",
      },
      body: JSON.stringify({
        email,
        ref: String(formData.get("ref") ?? "homepage").slice(0, 40),
        _gotcha: String(formData.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return { status: "error", message: "We couldn't reach the list. Try again.", email };
  }

  if (response.status === 429) {
    const wait = response.headers.get("Retry-After") ?? "60";
    return { status: "error", message: `The list is busy. Try again in ${wait} seconds.`, email };
  }
  const result = await response.json().catch(() => null);
  if (result?.ok) return { status: "joined", message: "You're on the list. We'll email you on launch day." };
  return {
    status: "error",
    message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
    email,
  };
}
```

- A verificação barata roda primeiro. Um e-mail inválido nunca custa uma requisição.
- `AbortSignal.timeout` impede que uma rede lenta deixe o botão no estado pendente para sempre.
- O `429` do Ikiform é lido à parte, com o `Retry-After` dele, porque com todos os visitantes atrás de um IP é o formulário inteiro que está ocupado, não essa pessoa.
- Todo erro devolve o e-mail, para o input poder mostrá-lo de novo. O React reinicia um formulário depois de uma action, e o `defaultValue` coloca o endereço de volta.
- O header `Origin` do navegador é repassado porque o Ikiform conta os envios de teste gratuitos por ele, e o `fetch` do próprio servidor não envia nenhum.
- O `submit()` do SDK não envia header `Authorization`, então não consegue pular os domínios permitidos. Use `fetch` com a chave aqui.

### O formulário, com um agradecimento otimista

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

```tsx
"use client";

import { useActionState, useOptimistic } from "react";
import { type JoinState, joinWaitlist } from "./actions";

const initial: JoinState = { status: "idle", message: "" };

export function WaitlistForm({ source }: { source: string }) {
  const [state, dispatch, pending] = useActionState(joinWaitlist, initial);
  const [shown, showJoined] = useOptimistic(
    state,
    (_current, email: string): JoinState => ({ status: "joined", message: `Adding ${email} to the list` }),
  );

  function join(formData: FormData) {
    showJoined(String(formData.get("email") ?? ""));
    dispatch(formData);
  }

  if (shown.status === "joined") {
    return (
      <p role="status" aria-busy={pending}>
        {shown.message}
      </p>
    );
  }

  return (
    <form action={join}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.email} />
      <input type="hidden" name="ref" value={source} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {shown.status === "error" ? <p role="alert">{shown.message}</p> : null}
      <button disabled={pending}>Join the waitlist</button>
    </form>
  );
}
```

- O `useOptimistic` precisa ser definido dentro de uma action. `join` é a action do formulário, então o React a roda numa transition e a frase otimista se mantém até o `dispatch` terminar.
- Em caso de sucesso, o action state vira `joined` e a frase fica, agora com a mensagem final. Num erro, o valor otimista some e o formulário volta com `state.email` e a mensagem.
- `aria-busy` avisa aos leitores de tela que a frase ainda está sendo confirmada.

> Envolver a action em `join` a transforma numa função do cliente, então este formulário precisa de JavaScript. Se enviar antes da hidratação importa mais que o agradecimento instantâneo, passe `dispatch` direto para `action` e remova o `useOptimistic`; o resto do código continua igual.

### A página

`app/waitlist/page.tsx`:

```tsx
import { WaitlistForm } from "./waitlist-form";

export default async function Page({ searchParams }: PageProps<"/waitlist">) {
  const { ref } = await searchParams;
  const source = typeof ref === "string" ? ref.slice(0, 40) : "homepage";

  return (
    <main>
      <h1>Acme is almost here</h1>
      <p>Leave your email and we'll send you an invite the day it opens.</p>
      <WaitlistForm source={source} />
    </main>
  );
}
```

A página lê `?ref=` da URL e o passa ao formulário, então um link como `/waitlist?ref=newsletter` marca cada inscrição com a origem. `PageProps` é o tipo ciente das rotas que o Next.js gera; ler `searchParams` renderiza a página a cada requisição.

## Confira se funciona

1. Rode `next dev`, abra `/waitlist?ref=test` e inscreva-se. A frase de agradecimento aparece antes de a requisição terminar.
2. Abra **Submissions** no painel. A inscrição está lá com `ref` definido como `test`.
3. Envie seis vezes em um minuto. A sexta mostra a mensagem de limite, e só cinco envios são armazenados.
4. Defina `IKIFORM_SECRET_KEY` com um valor errado e envie. A frase otimista é desfeita e o formulário mostra o erro com o seu e-mail ainda preenchido.
5. Confira a caixa de entrada de cada destinatário das notificações. Cada um recebe um e-mail com os campos, e **Reply** vai para quem enviou quando o formulário tem um campo `email`.
6. 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.

## Pegadinhas

| Problema | O que fazer |
| --- | --- |
| `403 forbidden` do Ikiform | A chave é de outro workspace, foi revogada ou não tem `submissions:write`. Crie uma chave secreta com esse escopo. |
| `429 rate_limited` | Um visitante enviou mais do que o limite de taxa do formulário permite. O Standard permite 5 por minuto por visitante. Espere o `Retry-After`, ou escolha Relaxed ou Custom para formulários que enviam a partir de um único IP compartilhado. |
| Todos os visitantes batem no limite ao mesmo tempo | O header de IP está ausente, então todo mundo conta como `unknown`. Leia o header que o seu host define, como `cf-connecting-ip` atrás da Cloudflare. |
| O agradecimento pisca e o formulário volta sem mensagem | A action lançou uma exceção em vez de devolver state. Mantenha todo caminho de falha como um `JoinState` devolvido, como no exemplo. |
| Um aviso de que uma atualização otimista aconteceu fora de uma transition | `showJoined` foi chamado a partir de um event handler. Chame-o dentro da action do formulário, como o `join` faz. |

## Perguntas

### Uma lista de espera em Next.js deve enviar pelo navegador ou por uma server action?

Os dois funcionam. Um envio pelo navegador mantém os limites por visitante, os domínios permitidos e a verificação humana do Ikiform. Uma server action esconde o endpoint, confere a entrada antes e funciona antes da hidratação, mas você adiciona seu próprio limite por visitante, porque todo envio vem do seu servidor.

### O useOptimistic funciona com o useActionState?

Sim. Defina o valor otimista e chame a função dispatch do useActionState dentro da mesma action, como a prop action do formulário. O valor otimista aparece enquanto a action roda e dá lugar ao action state quando ela termina.

### Preciso de um banco de dados para uma lista de espera em Next.js?

Não. A server action encaminha cada inscrição ao Ikiform, que a armazena, filtra spam, avisa você por e-mail e envia webhooks. Seu app não guarda nenhum estado além do contador do limite de taxa.

### 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 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.
- [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.
- [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.
- [Lista de espera para app mobile](https://ikiform.com/pt-br/guides/waitlist/mobile-app.md): Uma lista de espera pré-lançamento para um app iOS ou Android que pergunta qual celular a pessoa usa, manda testadores de iPhone ao TestFlight e preenche um teste fechado do Play.
- [Lista de espera no Framer](https://ikiform.com/pt-br/guides/waitlist/framer.md): Duas formas de coletar inscrições na lista de espera no Framer: o formulário do Framer com webhook para o Ikiform, ou um code component que mantém os limites por visitante e as verificações de spam.
