Formulário de inscrição em newsletter

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.

Preet SutharFundador, Ikiform

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:

CaminhoQuando rodaO que o leitor vê
Script chama actions.subscribe()JavaScript carregado, o caso comumA linha de status abaixo do formulário muda. Ele continua no artigo
Formulário envia a /newsletter?_action=...JavaScript desligado ou ainda não carregadoA 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.

Duas formas de uma inscrição no rodapé do Astro chegar à mesma actioncom JavaScriptsem JavaScriptartigoHTMLpré-renderizadoactions.subscribe()peloscriptdoclientehandlerdaactionzod,depoisfetchlinhadestatusmesmapáginaartigoenviodeformuláriosimples/newsletterprerender=falsehandlerdaactionmesmocódigopáginaderesultadogetActionResultFormData/_actions/subscribe{ data, error }POST?_actionresultadoDuas formas de uma inscrição no rodapé do Astro chegar à mesma actioncom JavaScriptsem JavaScriptartigoHTML pré-renderizadoactions.subscribe()pelo script do clientehandler da actionzod, depois fetchlinha de statusmesma páginaartigoenvio de formulário simples/newsletterprerender = falsehandler da actionmesmo códigopágina de resultadogetActionResultFormData/_actions/subscribe{ data, error }POST?_actionresultado
  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
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
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
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 na aba Fields do formulário.

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

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

src/pages/api/ikiform.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, 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

HostO 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

ProblemaO que fazer
O envio sem JavaScript mostra um 404 ou uma página estáticaO 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 funcionaO 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 ambienteO astro:env confere o schema no build. Defina a variável no host, ou no .env localmente.
402 plan_required em testes locaisO 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 chegouEle 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

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.