Lista de espera para um novo SaaS

Lista de espera para um novo SaaS8 min de leitura

Monte uma lista de espera em Astro

Uma lista de espera em Astro numa landing page estática: uma Action encaminha cada inscrição, um script a chama e uma página sob demanda pega os envios sem JavaScript.

Preet SutharFundador, Ikiform

Mantenha a landing page pré-renderizada e coloque o trabalho do servidor numa Astro Action que valida o e-mail e o encaminha ao seu backend de formulários com uma chave secreta. Um script curto chama a action para o visitante continuar na página, e o action simples do formulário aponta para uma página sob demanda que roda a mesma action quando o JavaScript não carregou.

O guia principal tem uma aba curta de Astro: uma action, um formulário e uma página renderizada sob demanda. Funciona, mas abre mão justamente do motivo pelo qual a maioria escolhe o Astro. Uma landing page é a página que você mais quer servida como HTML estático por uma CDN, e uma form action do Astro a obriga a ser renderizada a cada requisição. Esta página mantém a landing page estática e leva só a inscrição para o servidor.

O que muda no Astro

O Astro pré-renderiza todas as páginas por padrão. Isso tem dois efeitos numa lista de espera que os outros frameworks não têm:

ItemLanding page pré-renderizadaPágina renderizada sob demanda
Servida porUma CDN, como arquivoO servidor ou a função do seu adapter
<form action={actions.x}>Não permitido. O Astro precisa da página sob demandaFunciona sem JavaScript
?ref= na URLInvisível para o servidor. Gerada uma vez, sem queryLegível em Astro.url
Astro.clientAddressIndisponívelDisponível

Então a landing page não pode usar o padrão de form action, e não consegue ler ?ref= no build. A solução é dividir as tarefas: a página estática leva o formulário e um script, a action faz o trabalho do servidor, e uma pequena página, /join, é renderizada sob demanda para pegar um envio que chega sem JavaScript.

Dois caminhos até uma única Astro Action a partir de uma landing page estáticaCom JavaScriptSem JavaScriptpáginaestáticascriptpreencheref apartir daURLactions.joinWaitlist()roda noservidorIkiformchavesecreta,armazenafrase deagradecimentomesmapáginapáginaestáticaformulárioenvia para/join/joinsob demanda,Astro.callActionIkiforma mesmaaction/thanksredirecionamento303FormDataJSONresultadoPOSTJSON303Dois caminhos até uma única Astro Action a partir de uma landing page estáticaCom JavaScriptSem JavaScriptpágina estáticascript preenche ref a partir daURLactions.joinWaitlist()roda no servidorIkiformchave secreta, armazenafrase de agradecimentomesma páginapágina estáticaformulário envia para /join/joinsob demanda, Astro.callActionIkiforma mesma action/thanksredirecionamento 303FormDataJSONresultadoPOSTJSON303
  1. A landing page é HTML simples vindo da CDN. Quando o script dela carrega, ele copia ?ref= ou ?utm_source= para o input oculto ref.
  2. Com JavaScript, o script interrompe o envio normal e chama actions.joinWaitlist com os dados do formulário. A action roda no servidor e encaminha a inscrição ao Ikiform.
  3. O script troca o formulário por uma frase de agradecimento, ou mostra o erro abaixo do input.
  4. Sem JavaScript, o navegador envia o formulário para /join, que é renderizada sob demanda.
  5. /join chama a mesma action com Astro.callAction. Em caso de sucesso, redireciona para /thanks com um 303, para que um refresh não envie de novo. Num erro, mostra o formulário com a mensagem.
Uma action, duas entradas. Só o /join e a própria action precisam de servidor.

Configure o projeto

A configuração do produto, como criar o formulário e a chave secreta, está no guia de Astro da documentação. Os arquivos desta página:

Projeto
src/
  actions/index.ts
  components/WaitlistForm.astro
  pages/index.astro
  pages/join.astro
  pages/thanks.astro
astro.config.mjs
.env

Um adapter e um segredo tipado

As actions rodam no servidor no momento da requisição, então o projeto precisa de um adapter mesmo que a maioria das páginas continue estática. O astro add instala um e edita a configuração. Troque node por vercel, netlify ou cloudflare de acordo com o seu host.

Terminal
npx astro add node
astro.config.mjs
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});

envField com access: "secret" faz o IKIFORM_SECRET_KEY só poder ser importado de astro:env/server. Importe-o num script do cliente e o build falha, que é exatamente a ideia. Crie a chave em API keys com o escopo submissions:write.

.env
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 definição de campos

A action envia email e ref. Se você adicionar uma definição de campos na aba Fields do formulário, ela precisa listar os dois, ou o Ikiform recusa o envio com 422 e unknown_field:

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

A action

src/actions/index.ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";

const SOURCE = /^[a-z0-9_-]{1,40}$/;

export const server = {
  joinWaitlist: defineAction({
    accept: "form",
    input: z.object({
      email: z.email("Enter the email we should send your invite to."),
      ref: z
        .string()
        .nullish()
        .transform((value) => (value && SOURCE.test(value) ? value : "direct")),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const origin = context.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 ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          email: input.email,
          ref: input.ref,
          _gotcha: input._gotcha ?? "",
        }),
        signal: AbortSignal.timeout(8000),
      }).catch(() => null);

      if (!response) {
        throw new ActionError({ code: "GATEWAY_TIMEOUT", message: "We couldn't reach the list. Try again." });
      }
      if (response.status === 429) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Lots of people are joining. Try again in a minute." });
      }
      const result = await response.json().catch(() => null);
      if (result?.ok) return { joined: true };
      throw new ActionError({
        code: "BAD_REQUEST",
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      });
    },
  }),
};
  • accept: "form" interpreta o FormData. Inputs vazios chegam como null, então ref e o honeypot são nullish(), e um ref ausente vira direct.
  • ref precisa ser formado por letras minúsculas, dígitos, - ou _, até 40 caracteres. Qualquer outra coisa é armazenada como direct, para que um link forjado não encha sua exportação de lixo. O limite bate com o max_length da definição.
  • z.email() é o schema de e-mail de nível superior do Zod 4, exportado de astro/zod. Um endereço inválido falha aqui e nunca chega ao Ikiform.
  • O navegador envia um header Origin tanto na chamada do script quanto no envio simples. O Ikiform conta os envios de teste gratuitos por esse header e o fetch da própria action não envia nenhum, então a action o repassa.
  • O honeypot passa intacto, então o Ikiform continua marcando como spam os bots que o preenchem. Envios com a chave secreta pulam a verificação humana do Ikiform, então ligar o Spam check não acrescenta nada aqui; a action é a barreira.
  • Cada falha lança um ActionError com um código que corresponde ao que deu errado. O script e o /join leem error.message.

O componente do formulário

src/components/WaitlistForm.astro
---
interface Props {
  error?: string;
}

const { error = "" } = Astro.props;
---

<form method="POST" action="/join" data-waitlist>
  <label>
    Email
    <input name="email" type="email" autocomplete="email" required placeholder="you@company.com" />
  </label>
  <input type="hidden" name="ref" value="direct" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <p role="alert">{error}</p>
  <button>Join the waitlist</button>
</form>

<script>
  import { actions, isInputError } from "astro:actions";

  const query = new URLSearchParams(location.search);
  const source = (query.get("ref") ?? query.get("utm_source") ?? "").toLowerCase().slice(0, 40);

  for (const form of document.querySelectorAll<HTMLFormElement>("form[data-waitlist]")) {
    const ref = form.elements.namedItem("ref") as HTMLInputElement;
    const alert = form.querySelector<HTMLElement>("[role=alert]")!;
    const button = form.querySelector("button")!;
    if (source) ref.value = source;

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      button.textContent = "Joining";
      alert.textContent = "";

      const { error } = await actions.joinWaitlist(new FormData(form));
      if (!error) {
        const done = document.createElement("p");
        done.setAttribute("role", "status");
        done.textContent = "You're on the list. We'll email you on launch day.";
        form.replaceWith(done);
        return;
      }
      alert.textContent = isInputError(error) ? (error.fields.email?.[0] ?? "Check the address.") : error.message;
      button.disabled = false;
      button.textContent = "Join the waitlist";
    });
  }
</script>
  • O action do formulário é /join, uma URL normal. Sem JavaScript, o navegador envia para lá, então o formulário funciona antes de o script carregar ou se ele falhar.
  • O script lê ?ref= no navegador, porque uma página pré-renderizada foi gerada sem query string. Um visitante vindo de /?ref=newsletter é marcado como newsletter.
  • O Astro inclui o <script> de um componente uma vez por página, então o loop conecta todos os formulários de lista de espera dela. Coloque um no hero e outro no rodapé e os dois funcionam.
  • O label envolve o input em vez de apontar para um id, então duas cópias numa página não entram em conflito.

A landing page e o /join

src/pages/index.astro
---
import WaitlistForm from "../components/WaitlistForm.astro";
---

<main>
  <h1>Acme is almost here</h1>
  <p>Leave your email and we'll send you an invite the day it opens.</p>
  <WaitlistForm />
</main>

Sem export prerender, então ela continua estática. O /join sai dessa regra e trata o envio simples:

src/pages/join.astro
---
import { actions, isInputError } from "astro:actions";
import WaitlistForm from "../components/WaitlistForm.astro";

export const prerender = false;

let error = "";
if (Astro.request.method === "POST") {
  const result = await Astro.callAction(actions.joinWaitlist, await Astro.request.formData());
  if (!result.error) return Astro.redirect("/thanks", 303);
  error = isInputError(result.error) ? (result.error.fields.email?.[0] ?? "Check the address.") : result.error.message;
  Astro.response.status = 400;
}
---

<main>
  <h1>Join the Acme waitlist</h1>
  <WaitlistForm error={error} />
</main>

Astro.callAction roda a action com a mesma validação, então só há um lugar para mudar. O 303 manda o navegador para /thanks com um GET, que é uma página estática que você escreve com a mesma frase de agradecimento.

Notas de deploy

HostAdapterO segredo
Vercelnpx astro add vercelAdicione IKIFORM_SECRET_KEY em Environment Variables do projeto
Netlifynpx astro add netlifyAdicione em Site configuration › Environment variables
Cloudflarenpx astro add cloudflareAdicione como secret nas configurações do Worker, ou com wrangler secret put IKIFORM_SECRET_KEY

Em todo host, a landing page e o /thanks vão como arquivos estáticos, e a action mais o /join viram uma única função de servidor. Um dia tranquilo quase não custa nada, e um pico de lançamento atinge principalmente a CDN.

Confira se funciona

  1. Rode astro dev e abra http://localhost:4321/?ref=test. Inscreva-se. O formulário vira a frase de agradecimento sem recarregar.
  2. Abra Submissions no painel. A inscrição está lá com ref definido como test.
  3. Desligue o JavaScript no navegador, recarregue e inscreva-se de novo. Você cai em /thanks, e a nova inscrição tem ref definido como direct.
  4. Envie not-an-email com o JavaScript desligado. O /join mostra o formulário de novo com o erro de e-mail.
  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 astro 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
403 do seu próprio site no envio simplesO security.checkOrigin do Astro, ligado por padrão, recusa envios de formulário para páginas sob demanda vindos de outra origem. Envie a partir do mesmo host em que o site roda, como localhost:4321 em dev, não por um domínio de preview diferente.
Um erro de build sobre form actions numa página pré-renderizadaUm formulário usa action={actions.joinWaitlist} numa página estática. Mantenha o action como /join, como acima, ou adicione export const prerender = false a essa página.
ref é sempre directO link usa um valor com maiúsculas ou espaços, ou o script não carregou. O script passa o valor para minúsculas; o que sobrar fora de a-z, 0-9, - e _ é descartado pela action.
422 com unknown_fieldA definição de campos do formulário 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.