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

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

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](https://ikiform.com/pt-br/guides/waitlist.md#form) 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:

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

Diagrama: Dois caminhos até uma única Astro Action a partir de uma landing page estática

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](https://ikiform.com/pt-br/docs/guides/astro.md). Os arquivos desta página:

`Projeto`:

```text
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`:

```bash
npx astro add node
```

`astro.config.mjs`:

```js
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`:

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

### 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`:

```json
{
  "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`:

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

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

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

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

| 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

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](https://ikiform.com/pt-br/pricing).

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

### Uma página de lista de espera em Astro pode continuar estática?

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.

### As Astro Actions precisam de um adapter?

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.

### Por que não enviar o formulário Astro direto ao Ikiform?

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.

### 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 com Next.js](https://ikiform.com/pt-br/guides/waitlist/nextjs.md): 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.
- [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.
- [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.
- [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 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.
