# Crie um formulário de inscrição em newsletter no Astro

> Um formulário de newsletter no Astro que funciona em páginas pré-renderizadas: uma Astro Action confere o consentimento, envia ao Ikiform, e um endpoint adiciona leitores ao MailerLite.

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

Mantenha suas páginas Astro pré-renderizadas e coloque o formulário de inscrição no componente do rodapé. Sem JavaScript, ele envia a uma página `/newsletter` sob demanda que roda uma Astro Action; com JavaScript, um pequeno script chama a mesma action e o leitor nunca sai do artigo. A action valida o e-mail e o consentimento com `astro/zod`, envia ao seu backend de formulários com uma chave secreta, e um endpoint de API encaminha as inscrições verificadas ao MailerLite.

A [aba Astro do guia principal](https://ikiform.com/pt-br/guides/newsletter-signup.md#form) usa uma Action numa página renderizada sob demanda. A maioria dos sites Astro são blogs e documentações em que toda página é pré-renderizada, e um formulário no rodapé precisa estar em todas elas. Esta página mantém essas páginas estáticas e ainda roda a inscrição no servidor. Chaves e domínios permitidos estão no [guia do Astro na documentação](https://ikiform.com/pt-br/docs/guides/astro.md).

## O que as páginas estáticas mudam

A documentação do Astro diz que uma página precisa ser renderizada sob demanda para chamar uma action pelo atributo `action` de um formulário. Um artigo pré-renderizado não consegue tratar esse POST, porque não há servidor por trás. Há dois caminhos para contornar isso, e esta implementação usa os dois:

| Caminho | Quando roda | O que o leitor vê |
| --- | --- | --- |
| Script chama `actions.subscribe()` | JavaScript carregado, o caso comum | A linha de status abaixo do formulário muda. Ele continua no artigo |
| Formulário envia a `/newsletter?_action=...` | JavaScript desligado ou ainda não carregado | A página `/newsletter` sob demanda, com o resultado ou o erro |

Só `/newsletter` e o endpoint do webhook rodam sob demanda. Todo artigo continua sendo um arquivo estático, e o adapter adiciona uma função de servidor para essas duas rotas.

Diagrama: Duas formas de uma inscrição no rodapé do Astro chegar à mesma action

1. Com JavaScript, o script do rodapé impede o envio normal e chama a action com os dados do formulário.
2. A action valida a entrada, envia ao Ikiform e devolve dados ou um erro ao script, que atualiza a linha de status.
3. Sem JavaScript, o navegador envia o formulário à página /newsletter sob demanda, com a action na query string.
4. O Astro roda o mesmo handler da action, e a página lê o resultado com getActionResult e o mostra.

Um handler, duas portas de entrada. O artigo estático nunca precisa de um servidor próprio.

## Configuração do projeto

`Árvore do projeto`:

```text
src/
  actions/index.ts
  components/SignupForm.astro
  layouts/Base.astro
  pages/
    newsletter.astro
    api/ikiform.ts
astro.config.mjs
.env
```

Instale um adapter com `astro add vercel`, `astro add netlify` ou `astro add cloudflare`. Declare os segredos com `astro:env` em vez de ler `import.meta.env`: o schema faz o build falhar quando falta uma variável, e o mesmo import funciona em todo adapter, inclusive no runtime da Cloudflare.

`astro.config.mjs`:

```js
import { defineConfig, envField } from "astro/config";
import vercel from "@astrojs/vercel";

export default defineConfig({
  adapter: vercel(),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      IKIFORM_WEBHOOK_SECRET: envField.string({ context: "server", access: "secret" }),
      MAILERLITE_API_KEY: envField.string({ context: "server", access: "secret" }),
      MAILERLITE_GROUP_ID: envField.string({ context: "server", access: "secret" }),
    },
  },
});
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789
```

Crie a chave secreta em **API keys** com o escopo `submissions:write` e cole a [definição de campos do pilar](https://ikiform.com/pt-br/guides/newsletter-signup.md#fields) na aba **Fields** do formulário.

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

export const server = {
  subscribe: defineAction({
    accept: "form",
    input: z.object({
      email: z.email({ error: "Enter an email address we can send the newsletter to." }),
      first_name: z.string().trim().max(80).nullish(),
      consent: z.boolean().refine((ticked) => ticked, { error: "Tick the box so we know you want the emails." }),
      source: z.enum(["footer", "post", "newsletter-page"]).catch("footer"),
      _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,
          first_name: input.first_name || undefined,
          consent: true,
          source: input.source,
          _gotcha: input._gotcha ?? "",
        }),
      }).catch(() => null);

      const result = await response?.json().catch(() => null);
      if (result?.ok) return { subscribed: true };
      throw new ActionError({
        code: response?.status === 429 ? "TOO_MANY_REQUESTS" : "BAD_REQUEST",
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "We couldn't reach the list. Try again.",
      });
    },
  }),
};
```

- `accept: "form"` faz o Astro interpretar o `FormData`. O checkbox passa por coerção booleana, então marcado vira `true` e desmarcado vira `false`, que o `refine` transforma na mensagem de consentimento.
- Inputs de texto vazios chegam como `null`, então campos opcionais usam `nullish()`, não `optional()`.
- `source` volta para `footer` com qualquer valor fora da lista, então um envio forjado não consegue inventar posições.
- A linha `Origin` repassa a origem do navegador, porque o Ikiform reconhece os testes gratuitos em `localhost` por esse header e o `fetch` de um servidor não envia nenhum.
- Os erros do Ikiform viram um `ActionError`. Um `429` mantém o significado como `TOO_MANY_REQUESTS`.

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

### O componente do rodapé

`src/components/SignupForm.astro`:

```astro
---
import { actions } from "astro:actions";

interface Props {
  source?: "footer" | "post" | "newsletter-page";
}

const { source = "footer" } = Astro.props;
const target = new URL("/newsletter", Astro.url);
target.search = actions.subscribe.queryString;
---

<form class="signup" method="POST" action={target.pathname + target.search}>
  <label>Email <input name="email" type="email" required autocomplete="email" /></label>
  <label>First name (optional) <input name="first_name" maxlength="80" autocomplete="given-name" /></label>
  <label>
    <input name="consent" type="checkbox" required />
    Email me the newsletter. I can unsubscribe at any time.
  </label>
  <input type="hidden" name="source" value={source} />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <p class="signup-status" role="status"></p>
  <button>Subscribe</button>
</form>

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

  for (const form of document.querySelectorAll<HTMLFormElement>("form.signup")) {
    const status = form.querySelector(".signup-status") as HTMLElement;
    const button = form.querySelector("button") as HTMLButtonElement;

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      status.textContent = "Subscribing";
      const { error } = await actions.subscribe(new FormData(form));
      button.disabled = false;
      if (!error) {
        form.replaceChildren(status);
        status.textContent = "Almost done. Open the email we sent you and confirm your address.";
        return;
      }
      const fields = isInputError(error) ? error.fields : {};
      status.textContent = fields.email?.[0] ?? fields.consent?.[0] ?? error.message;
    });
  }
</script>
```

- `queryString` é a forma documentada de montar a URL de uma action. Colocá-lo numa URL `/newsletter` manda os envios sem JavaScript para a página sob demanda em vez da página estática de onde vieram.
- O Astro empacota o `<script>` de um componente uma vez só, não importa quantas vezes o componente apareça. O loop conecta todas as cópias, como um formulário no rodapé e outro no fim de um post.
- `isInputError` separa as mensagens por campo do zod das recusas do Ikiform, que chegam como uma mensagem simples.

### A página sob demanda

`src/pages/newsletter.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";
import SignupForm from "../components/SignupForm.astro";
import Base from "../layouts/Base.astro";

export const prerender = false;

const result = Astro.getActionResult(actions.subscribe);
const fields = isInputError(result?.error) ? result.error.fields : {};
const problem = fields.email?.[0] ?? fields.consent?.[0] ?? result?.error?.message;
---

<Base title="Newsletter">
  <h1>The monthly newsletter</h1>
  {result?.data?.subscribed ? (
    <p role="status">Almost done. Open the email we sent you and confirm your address.</p>
  ) : (
    <>
      {problem && <p role="alert">{problem}</p>}
      <SignupForm source="newsletter-page" />
    </>
  )}
</Base>
```

O Astro mostra o resultado de uma action apenas para aquele POST. Visitar a página de novo o apaga, o que não é problema para uma inscrição. Se preferir redirecionar para uma URL de agradecimento, a documentação do Astro mostra o padrão com sessão usando `getActionContext` no middleware.

## Encaminhe assinantes ao MailerLite

Este endpoint recebe o webhook assinado do Ikiform e adiciona cada inscrição a um grupo do MailerLite como `unconfirmed`, para que o MailerLite envie a própria confirmação quando **Double opt-in for API and integrations** estiver ligado. Ele lê o corpo como texto primeiro porque a assinatura cobre os bytes exatos. Ele envia toda inscrição como `unconfirmed`, o que serve para uma lista nova. Se sua lista já tem leitores confirmados, use o relay do [guia do MailerLite](https://ikiform.com/pt-br/guides/newsletter-signup/mailerlite.md): ele consulta o endereço antes, então um leitor confirmado que se inscreve de novo mantém o status.

`src/pages/api/ikiform.ts`:

```ts
import type { APIRoute } from "astro";
import { IKIFORM_WEBHOOK_SECRET, MAILERLITE_API_KEY, MAILERLITE_GROUP_ID } from "astro:env/server";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  let event;
  try {
    event = await verifyWebhook({ payload: await request.text(), headers: request.headers, secret: IKIFORM_WEBHOOK_SECRET });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  if (event.type !== "submission.created") return new Response(null, { status: 204 });

  const { email, first_name, consent } = event.data.fields;
  if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });

  const answer = await fetch("https://connect.mailerlite.com/api/subscribers", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${MAILERLITE_API_KEY}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      email,
      fields: typeof first_name === "string" && first_name ? { name: first_name } : {},
      groups: [MAILERLITE_GROUP_ID],
      status: "unconfirmed",
    }),
  });

  if (answer.status === 429) return new Response("MailerLite rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!answer.ok) return new Response(`MailerLite answered ${answer.status}`, { status: 502 });
  return new Response(null, { status: 204 });
};
```

O `security.checkOrigin` do Astro, ligado por padrão, rejeita envios de formulário cross-site para rotas sob demanda. O webhook do Ikiform é `application/json`, um tipo ao qual a checagem não se aplica, então o endpoint o recebe normalmente. Adicione a URL em **Webhooks** e copie o segredo `whsec_` para `IKIFORM_WEBHOOK_SECRET`.

## Honeypot e a verificação humana

O input `_gotcha` é repassado, e o Ikiform armazena um preenchido como spam, com uma resposta de sucesso e sem webhook, então ele nunca chega ao MailerLite. A chave secreta pula a verificação Turnstile do Ikiform. Se bots passarem, você tem duas opções: verificar um token do Turnstile no handler, como mostra o [guia do Turnstile](https://ikiform.com/pt-br/guides/spam-protection/cloudflare-turnstile.md), ou largar a action e enviar a partir do navegador com `@ikiform/sdk`, que mantém a verificação do Ikiform, os domínios permitidos e os limites por visitante e não precisa de adapter nenhum.

## Deploy

| Host | O que fazer |
| --- | --- |
| Vercel | `@astrojs/vercel` transforma as duas rotas sob demanda em funções. Adicione as quatro variáveis em **Environment Variables**. |
| Netlify | `@astrojs/netlify` faz o mesmo com Netlify Functions. Adicione as variáveis nas configurações de ambiente do site. |
| Cloudflare | `@astrojs/cloudflare` tem Workers como alvo; o adapter não suporta mais Pages. Defina os segredos com `wrangler secret put` e use `.dev.vars` localmente. Dê ao Worker um domínio personalizado, porque o Ikiform recusa URLs de webhook em `workers.dev`. |

## Teste em localhost de graça

Um workspace sem plano aceita 50 envios de teste por mês vindos de `localhost`. Rode `astro dev` e o `Origin` repassado é `http://localhost:4321`, então cada inscrição conta como teste. Teste os dois caminhos: uma vez normalmente e outra com o JavaScript desativado, para ver a página `/newsletter`. Para testar o endpoint, exponha-o por um túnel com URL HTTPS; o Ikiform não entrega em `localhost`.

## Confira se funciona

1. Rode `astro build` e confira se seus artigos continuam listados como pré-renderizados na saída, com só `/newsletter` e `/api/ikiform` sob demanda.
2. Inscreva-se pelo rodapé de um artigo. A linha de status muda e a URL não.
3. Desative o JavaScript e inscreva-se de novo. Você cai em `/newsletter` com a frase de confira-sua-caixa-de-entrada.
4. Envie com o checkbox desmarcado pelas dev tools. A mensagem de consentimento aparece e nada é armazenado.
5. Abra **Submissions** no painel. O novo envio está lá com todos os campos que você enviou.
6. Se você adicionou um webhook, abra o endpoint em **Webhooks**. A entrega mostra `succeeded` com a resposta do seu servidor.
7. Abra o MailerLite. O assinante está no grupo como não confirmado, e o e-mail de confirmação está na sua caixa de entrada.

## Pegadinhas

| Problema | O que fazer |
| --- | --- |
| O envio sem JavaScript mostra um 404 ou uma página estática | O formulário enviou à página pré-renderizada de onde veio. Monte o `action` a partir de `/newsletter` mais `queryString` e mantenha `prerender = false` nessa página. |
| Só o primeiro formulário da página funciona | O script usou `querySelector` para um único formulário. O Astro o roda uma vez por página, então percorra todos os `form.signup`. |
| O build falha por falta de uma variável de ambiente | O `astro:env` confere o schema no build. Defina a variável no host, ou no `.env` localmente. |
| `402 plan_required` em testes locais | O envio não tinha origem `localhost`. Confira a linha `Origin` no handler e abra o site em `localhost`. |
| O envio está no painel, mas nenhum e-mail ou webhook chegou | Ele foi armazenado como spam. Spam não envia nada. Abra-o no filtro **Spam** para ver os motivos e marque-o como não spam para enviar o e-mail e os webhooks dele. |

## Perguntas

### Uma Astro Action pode rodar numa página pré-renderizada?

Chamada por um script no cliente, sim, desde que o projeto tenha um adapter: a action roda na própria rota sob demanda. Um envio de formulário HTML simples precisa de uma página sob demanda para chegar, e é por isso que esta implementação o manda para /newsletter.

### Preciso de um adapter para um formulário de newsletter no Astro?

Só para a versão no servidor desta página. Um site Astro totalmente estático pode enviar o formulário direto do navegador ao Ikiform, o que mantém a verificação humana do Ikiform e dispensa servidor.

### Por que encaminhar inscrições ao MailerLite por webhook em vez de na action?

A action não consegue distinguir um bot de um leitor: o Ikiform dá aos dois a mesma resposta de sucesso. O webhook dele só dispara para inscrições que passam pela checagem de spam, e uma queda do MailerLite então só atrasa o assinante via novas tentativas do webhook, em vez de fazer o envio do visitante falhar.

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

- [Formulário de inscrição em newsletter](https://ikiform.com/pt-br/guides/newsletter-signup.md): Crie um formulário de newsletter no seu próprio markup, registre o consentimento, barre inscrições de bots e passe cada assinante para Kit, MailerLite, beehiiv ou qualquer ferramenta.
- [Site estático](https://ikiform.com/pt-br/guides/newsletter-signup/static-site.md): Adicione um formulário de newsletter a um site estático em Hugo, Jekyll, Eleventy ou Astro, mantenha a chave de API da ferramenta de e-mail fora da página e confirme na ferramenta.
- [MailerLite](https://ikiform.com/pt-br/guides/newsletter-signup/mailerlite.md): Conecte seu próprio formulário de inscrição ao MailerLite: um relay adiciona novos leitores como não confirmados para o double opt-in e nunca pede a um assinante que confirme duas vezes.
- [Formulário de newsletter no Next.js](https://ikiform.com/pt-br/guides/newsletter-signup/nextjs.md): Um formulário de newsletter no rodapé do Next.js: uma server action com checagem de consentimento e Turnstile, enviando ao Ikiform, e um route handler que adiciona leitores ao Kit.
- [Double opt-in](https://ikiform.com/pt-br/guides/newsletter-signup/double-opt-in.md): O Ikiform não envia e-mails de confirmação, então o double opt-in roda na sua ferramenta de e-mail: um relay adiciona cada inscrição como não confirmada e a ferramenta envia o link.
- [Kit (ConvertKit)](https://ikiform.com/pt-br/guides/newsletter-signup/kit.md): Conecte seu próprio formulário de newsletter ao Kit com um relay que cria o assinante, o adiciona a um formulário do Kit para o incentive email e marca a posição com uma tag.
