Formulário de inscrição em newsletter9 min de leitura
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.
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 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.
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.
- Com JavaScript, o script do rodapé impede o envio normal e chama a action com os dados do formulário.
- A action valida a entrada, envia ao Ikiform e devolve dados ou um erro ao script, que atualiza a linha de status.
- Sem JavaScript, o navegador envia o formulário à página /newsletter sob demanda, com a action na query string.
- O Astro roda o mesmo handler da action, e a página lê o resultado com getActionResult e o mostra.
Configuração do projeto
src/
actions/index.ts
components/SignupForm.astro
layouts/Base.astro
pages/
newsletter.astro
api/ikiform.ts
astro.config.mjs
.envInstale 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.
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" }),
},
},
});IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789Crie a chave secreta em API keys com o escopo submissions:write e cole a definição de campos do pilar na aba Fields do formulário.
Construa
A action
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 oFormData. O checkbox passa por coerção booleana, então marcado viratruee desmarcado virafalse, que orefinetransforma na mensagem de consentimento.- Inputs de texto vazios chegam como
null, então campos opcionais usamnullish(), nãooptional(). sourcevolta parafootercom qualquer valor fora da lista, então um envio forjado não consegue inventar posições.- A linha
Originrepassa a origem do navegador, porque o Ikiform reconhece os testes gratuitos emlocalhostpor esse header e ofetchde um servidor não envia nenhum. - Os erros do Ikiform viram um
ActionError. Um429mantém o significado comoTOO_MANY_REQUESTS.
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.O componente do rodapé
---
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/newslettermanda 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. isInputErrorsepara as mensagens por campo do zod das recusas do Ikiform, que chegam como uma mensagem simples.
A página sob demanda
---
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: ele consulta o endereço antes, então um leitor confirmado que se inscreve de novo mantém o status.
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, 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
- Rode
astro builde confira se seus artigos continuam listados como pré-renderizados na saída, com só/newslettere/api/ikiformsob demanda. - Inscreva-se pelo rodapé de um artigo. A linha de status muda e a URL não.
- Desative o JavaScript e inscreva-se de novo. Você cai em
/newslettercom a frase de confira-sua-caixa-de-entrada. - Envie com o checkbox desmarcado pelas dev tools. A mensagem de consentimento aparece e nada é armazenado.
- Abra Submissions no painel. O novo envio está lá com todos os campos que você enviou.
- Se você adicionou um webhook, abra o endpoint em Webhooks. A entrega mostra
succeededcom a resposta do seu servidor. - 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
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.
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.
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.
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.