Lista de espera para um novo SaaS

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.

Preet SutharFundador, Ikiform

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:

ItemNavegador envia ao IkiformServer action com chave secreta
Quem o Ikiform vêO IP de cada visitanteO IP do seu servidor, para todos os visitantes
Limite de taxa por visitanteO do Ikiform, por IPPor sua conta, mostrado abaixo
Domínios permitidos e a verificação humanaRodamPulados, porque a chave prova que o envio é seu
Quem pode chamarQualquer um, a partir dos seus domínios permitidosQualquer 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.

Como uma inscrição passa pela server action do Next.jsformuláriouseOptimisticmostra oagradecimentoserveractionconferee-mail,limita porIPIkiformchavesecreta,armazena ainscriçãoactionstateinscrito, ouum erroestado deerronada éenviado, oformuláriovoltaFormDataJSONresultadoacima do limiteComo uma inscrição passa pela server action do Next.jsformuláriouseOptimistic mostrao agradecimentoserver actionconfere e-mail, limitapor IPIkiformchave secreta,armazena a inscriçãoaction stateinscrito, ou um erroestado deerronada éenviado, oformuláriovoltaFormDataJSONresultadoacima do limite
  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
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.

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

ProblemaO que fazer
403 forbidden do IkiformA chave é de outro workspace, foi revogada ou não tem submissions:write. Crie uma chave secreta com esse escopo.
429 rate_limitedUm 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 tempoO 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 mensagemA 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 transitionshowJoined foi chamado a partir de um event handler. Chame-o dentro da action do formulário, como o join faz.

Perguntas

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