Lista de espera para um novo SaaS7 min de leitura
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.
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 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:
| Item | 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.
- O visitante clica no botão. O useOptimistic troca o formulário pela frase de agradecimento na hora.
- A server action confere o e-mail e conta as tentativas recentes do visitante pelo IP.
- Dentro do limite, ela envia a inscrição ao Ikiform com a chave secreta.
- 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.
- Acima do limite, nada é enviado e o estado de erro volta na hora.
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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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.
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, leiacf-connecting-ip. Um header que o próprio visitante pode definir não limita nada.
A server action
"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.timeoutimpede que uma rede lenta deixe o botão no estado pendente para sempre.- O
429do Ikiform é lido à parte, com oRetry-Afterdele, 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
defaultValuecoloca o endereço de volta. - O header
Origindo navegador é repassado porque o Ikiform conta os envios de teste gratuitos por ele, e ofetchdo próprio servidor não envia nenhum. - O
submit()do SDK não envia headerAuthorization, então não consegue pular os domínios permitidos. Usefetchcom a chave aqui.
O formulário, com um agradecimento otimista
"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
useOptimisticprecisa 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é odispatchterminar. - Em caso de sucesso, o action state vira
joinede a frase fica, agora com a mensagem final. Num erro, o valor otimista some e o formulário volta comstate.emaile a mensagem. aria-busyavisa aos leitores de tela que a frase ainda está sendo confirmada.
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
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
- Rode
next dev, abra/waitlist?ref=teste inscreva-se. A frase de agradecimento aparece antes de a requisição terminar. - Abra Submissions no painel. A inscrição está lá com
refdefinido comotest. - Envie seis vezes em um minuto. A sexta mostra a mensagem de limite, e só cinco envios são armazenados.
- Defina
IKIFORM_SECRET_KEYcom um valor errado e envie. A frase otimista é desfeita e o formulário mostra o erro com o seu e-mail ainda preenchido. - 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. - 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
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.
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.
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.
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.