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.
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:
| Item | Landing page pré-renderizada | Página renderizada sob demanda |
|---|---|---|
| Servida por | Uma CDN, como arquivo | O servidor ou a função do seu adapter |
<form action={actions.x}> | Não permitido. O Astro precisa da página sob demanda | Funciona sem JavaScript |
?ref= na URL | Invisível para o servidor. Gerada uma vez, sem query | Legível em Astro.url |
Astro.clientAddress | Indisponível | Disponí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.
- A landing page é HTML simples vindo da CDN. Quando o script dela carrega, ele copia ?ref= ou ?utm_source= para o input oculto ref.
- 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.
- O script troca o formulário por uma frase de agradecimento, ou mostra o erro abaixo do input.
- Sem JavaScript, o navegador envia o formulário para /join, que é renderizada sob demanda.
- /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.
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:
src/
actions/index.ts
components/WaitlistForm.astro
pages/index.astro
pages/join.astro
pages/thanks.astro
astro.config.mjs
.envUm 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.
npx astro add nodeimport { 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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:
{
"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
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 oFormData. Inputs vazios chegam comonull, entãorefe o honeypot sãonullish(), e umrefausente viradirect.refprecisa ser formado por letras minúsculas, dígitos,-ou_, até 40 caracteres. Qualquer outra coisa é armazenada comodirect, para que um link forjado não encha sua exportação de lixo. O limite bate com omax_lengthda definição.z.email()é o schema de e-mail de nível superior do Zod 4, exportado deastro/zod. Um endereço inválido falha aqui e nunca chega ao Ikiform.- O navegador envia um header
Origintanto na chamada do script quanto no envio simples. O Ikiform conta os envios de teste gratuitos por esse header e ofetchda 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
ActionErrorcom um código que corresponde ao que deu errado. O script e o/joinleemerror.message.
O componente do formulário
---
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
actiondo 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 comonewsletter. - 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
---
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:
---
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
| Host | Adapter | O segredo |
|---|---|---|
| Vercel | npx astro add vercel | Adicione IKIFORM_SECRET_KEY em Environment Variables do projeto |
| Netlify | npx astro add netlify | Adicione em Site configuration › Environment variables |
| Cloudflare | npx astro add cloudflare | Adicione 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
- Rode
astro deve abrahttp://localhost:4321/?ref=test. Inscreva-se. O formulário vira a frase de agradecimento sem recarregar. - Abra Submissions no painel. A inscrição está lá com
refdefinido comotest. - Desligue o JavaScript no navegador, recarregue e inscreva-se de novo. Você cai em
/thanks, e a nova inscrição temrefdefinido comodirect. - Envie
not-an-emailcom o JavaScript desligado. O/joinmostra o formulário de novo com o erro de e-mail. - 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. - Envios do
astro devtrazem a origemlocalhost, 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 delocalhost. 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
| Problema | O que fazer |
|---|---|
403 do seu próprio site no envio simples | O 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é-renderizada | Um 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 direct | O 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_field | A definição de campos do formulário 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
Sim. Deixe a landing page pré-renderizada, chame a action a partir de um script do cliente e aponte o action simples do formulário para uma página renderizada sob demanda. Só a action e essa página precisam de servidor.
Sim. As actions rodam no servidor quando são chamadas, então o projeto precisa de um adapter como Node, Vercel, Netlify ou Cloudflare, mesmo que todas as páginas sejam pré-renderizadas.
Você pode, e isso mantém o limite de taxa por visitante e a verificação humana do Ikiform. A action vale a pena quando você quer validar e limpar a tag de origem antes, ou manter o endpoint fora do HTML da página.
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.